Skip to content

Async State

Handling loading, error, and success states is one of the most complex parts of UI development. Storion provides a purpose-built async system that handles cancellation, caching, and state transitions automatically.

The Problem

Traditional async handling in React leads to boilerplate and bugs:

tsx
// ❌ Manual async handling - verbose and error-prone
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetchUser(userId)
      .then((data) => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [userId]);

  // Still need to handle all states in render...
}

Storion's async system handles all of this:

tsx
// ✅ Storion - clean and automatic
function UserProfile({ userId }) {
  const { user } = useStore(({ get }) => {
    const [state, actions] = get(userStore);
    trigger(actions.fetchUser, [userId], userId);
    return { user: state.user };
  });

  if (user.status === "pending") return <Spinner />;
  if (user.status === "error") return <Error error={user.error} />;
  return <div>{user.data?.name}</div>;
}

Installation

ts
import { async } from "storion/async";

Naming Convention

Use semantic names that describe the operation type:

TypePatternExample
Read operations*QueryuserQuery, postsQuery
Write operations*MutationcreateUserMutation, updatePostMutation
Other operations*ActionuploadAction, downloadAction

Two Modes: Fresh vs Stale

Storion offers two strategies for handling data during loading states:

Fresh Mode (Suspense-compatible)

Data is undefined during loading. Best for:

  • Initial page loads
  • Data that shouldn't show stale values
  • Suspense-based UIs
ts
const userStore = store({
  name: "user",
  state: {
    user: async.fresh<User>(), // undefined during loading
  },
  setup({ focus }) {
    const userQuery = async.action(focus("user"), async (ctx, id: string) => {
      const res = await fetch(`/api/users/${id}`, { signal: ctx.signal });
      return res.json();
    });

    return {
      fetchUser: userQuery.dispatch,
    };
  },
});

Stale Mode (Keep Previous Data)

Preserves previous data during loading. Best for:

  • Lists that shouldn't flash empty
  • Paginated data
  • "Stale-while-revalidate" patterns
ts
const userStore = store({
  name: "user",
  state: {
    users: async.stale<User[]>([]), // Shows [] while loading, then previous data
  },
  setup({ focus }) {
    const usersQuery = async.action(focus("users"), async (ctx) => {
      const res = await fetch("/api/users", { signal: ctx.signal });
      return res.json();
    });

    return {
      fetchUsers: usersQuery.dispatch,
      refreshUsers: usersQuery.refresh,
    };
  },
});

Accessing Async State

Both modes give you the same async state shape:

ts
interface AsyncState<T> {
  status: "idle" | "pending" | "success" | "error";
  data: T | undefined; // The resolved data
  error: Error | undefined; // The error if status is 'error'
  mode: "fresh" | "stale"; // Which mode this state uses
}

You can access this directly and check the status manually (works the same for both modes):

tsx
const { user } = useStore(({ get }) => {
  const [state] = get(userStore);
  return { user: state.user }; // AsyncState<User>
});

// Check status manually - same for Fresh and Stale
if (user.status === "pending") return <Spinner />;
if (user.status === "error") return <Error error={user.error} />;
return <div>{user.data?.name}</div>;

Using async.wait() to Extract Data

async.wait() extracts the data from an async state, but behaves differently based on the mode:

tsx
// With async.wait() - behavior depends on mode
const { user } = useStore(({ get }) => {
  const [state] = get(userStore);
  return { user: async.wait(state.user) }; // User (not AsyncState<User>)
});

// No status checks needed - async.wait() handles it
return <div>{user.name}</div>;

async.wait() Behavior by Mode:

StatusFresh ModeStale Mode
idle❌ Throws AsyncNotReadyError✅ Returns initial data
pending❌ Throws promise (Suspense)✅ Returns previous data
success✅ Returns data✅ Returns data
error❌ Throws error✅ Returns previous data

Fresh mode is designed for Suspense - throwing promises lets React show fallback UI.

Stale mode never throws - it always returns the best available data (current, previous, or initial).

Using Async State in Components

Pattern 1: Status Checks

The most explicit approach - check status and render accordingly:

tsx
function UserProfile({ userId }: { userId: string }) {
  const { user } = useStore(({ get }) => {
    const [state, actions] = get(userStore);

    // Trigger fetch when userId changes
    trigger(actions.fetchUser, [userId], userId);

    return { user: state.user };
  });

  // Handle all states explicitly
  if (user.status === "idle") return <button>Load User</button>;
  if (user.status === "pending") return <Spinner />;
  if (user.status === "error") return <Error error={user.error} />;

  return <div>{user.data.name}</div>;
}

Pattern 2: With Suspense (Fresh Mode)

Let React Suspense handle the loading state:

tsx
function UserProfile({ userId }: { userId: string }) {
  const { user } = useStore(({ get }) => {
    const [state, actions] = get(userStore);
    trigger(actions.fetchUser, [userId], userId);

    // async.wait() throws promise for Suspense
    return { user: async.wait(state.user) };
  });

  // No loading check needed - Suspense handles it
  return <div>{user.name}</div>;
}

// Wrap with Suspense boundary
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userId="123" />
    </Suspense>
  );
}

Pattern 3: Stale While Revalidate

Show previous data while fetching fresh data:

tsx
function UserList() {
  const { users, isRefreshing } = useStore(({ get }) => {
    const [state, actions] = get(userStore);
    trigger(actions.fetchUsers, []);

    return {
      // Stale mode: always returns data (previous or current)
      users: state.users.data,
      isRefreshing: state.users.status === "pending",
    };
  });

  return (
    <div>
      {isRefreshing && <RefreshIndicator />}
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Automatic Cancellation

Requests are automatically cancelled when:

  1. Component unmounts — no stale updates
  2. New request starts — prevents race conditions
  3. Store is disposed — cleanup on teardown

Always use ctx.signal in your fetch calls:

ts
async.action(focus("data"), async (ctx, query: string) => {
  const res = await fetch(`/api/search?q=${query}`, {
    signal: ctx.signal, // Aborts on cancellation
  });
  return res.json();
});

trigger() Rules

Critical Rule

Never pass anonymous functions to trigger(). Anonymous functions create new references each render, causing the function to be called repeatedly.

ts
// ❌ WRONG - new function reference each render = infinite calls
trigger(() => actions.fetch(id), [id]);

// ✅ CORRECT - stable function reference
trigger(actions.fetch, [id], id);

How trigger() works:

ts
trigger(fn, deps, ...args);
//      ↑   ↑      ↑
//      |   |      └── Arguments passed to fn
//      |   └── Dependency array (like useEffect deps)
//      └── Stable function reference

Component-Local Mutations

For operations that don't need shared state (form submissions, one-off actions), use async.mixin():

tsx
const submitFormMutation = async.mixin(async (ctx, data: FormData) => {
  const res = await fetch("/api/submit", {
    method: "POST",
    body: JSON.stringify(data),
    signal: ctx.signal,
  });
  return res.json();
});

function ContactForm() {
  const [state, { dispatch }] = useStore(({ mixin }) => {
    return mixin(submitFormMutation);
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        dispatch(new FormData(e.target));
      }}
    >
      <button disabled={state.status === "pending"}>
        {state.status === "pending" ? "Submitting..." : "Submit"}
      </button>
      {state.status === "error" && <p>{state.error.message}</p>}
    </form>
  );
}

Why Storion Async?

Compared to React Query, RTK Query, and Apollo:

AdvantageDescription
Full controlWrite any custom logic - conditional fetching, transformations, multi-fetch
No hook compositionCombine multiple API calls in one action instead of multiple useQuery
Framework agnosticUse same stores in Node.js, React Native, or background tasks
Component-local mutationsNative mixin pattern for forms without global cache pollution
Network-aware retryBuilt-in support for waiting on network reconnection
Dependency injectionEasy testing with mock services
Unified stateAsync state lives alongside regular state in the same store

Combining Multiple Async States

Storion provides combinators for working with multiple async states:

ts
import { async } from "storion/async";

// Wait for all - array form (returns tuple)
const [user, posts] = async.all([state.user, state.posts]);

// Wait for all - record form (returns object)
const { user, posts } = async.all({
  user: state.user,
  posts: state.posts,
});

// Race - get first ready result
const [source, data] = async.race({
  cache: state.cache,
  network: state.network,
});

// Any - get first success (like Promise.any)
const userData = async.any([state.primary, state.fallback]);

// Settled - get all results without throwing
const results = async.settled([state.a, state.b, state.c]);

These combinators accept both AsyncState and raw PromiseLike - no wrapping needed:

ts
// Mix AsyncState with raw promises directly
const [cachedUser, freshPosts] = async.all([
  state.user, // AsyncState
  fetch("/api/posts").then((r) => r.json()), // PromiseLike
]);

// Race between cached state and fresh fetch
const [source, data] = async.race({
  cache: state.cachedData,
  fresh: fetch("/api/data").then((r) => r.json()),
});

TIP

You can also use async.state(promise) to explicitly convert a promise to PromiseWithState if you need to access the state synchronously:

ts
const pws = async.state(fetchData());
pws.state.status; // "pending" | "fulfilled" | "rejected"

See the detailed API reference for complete documentation on async.all(), async.race(), async.any(), async.settled(), async.derive(), and more.

Next Steps

Released under the MIT License.