Skip to main content

SolidJS 2.0 Async Data: A Deep Dive for React Devs

On this page
The SolidJS logo

In my first look at SolidJS 2.0 I said first-class async was the real story, then barely opened the box. This post opens it.

Here’s the short version. In Solid 2.0 a promise is just another value the reactive graph knows how to wait for. A computation can return one, anything that reads it suspends until it resolves, and a <Loading> boundary shows a fallback only until the first real value lands. There’s no createResource, no manual await in your components, and no re-render. If you write React, this is the part that behaves least like what you’re used to, so it’s worth slowing down on.

Everything below is from the 2.0.0-beta.18 line (npm install solid-js@next). It’s a beta, and some of these names will still move before stable, so I’ve flagged the parts that are rougher.

How does async data fetching work in Solid 2.0?

A derived computation can return a promise, and the reactive graph treats the pending state as “not ready” until it settles. createResource is gone; a plain createMemo that returns a promise does the job, as the async RFC puts it: async is “a first-class capability of computations.”

// Solid 1.x
const [user] = createResource(userId, fetchUser);

// Solid 2.0: the memo returns a promise, the graph waits on it
const user = createMemo(() => fetchUser(userId()));

Read user() anywhere and you get the resolved value. While the promise is in flight the read throws a NotReadyError internally, which propagates up the graph to the nearest boundary rather than crashing. You never catch it yourself; it’s the mechanism that lets <Loading> and error boundaries work without a dedicated suspense primitive.

When userId() changes, the memo re-runs, returns a new promise, and the graph waits again. Same accessor, no dependency array, no effect wired up to refetch.

If you need the value imperatively outside a reactive scope (a route loader, an event handler), resolve hands you a real promise that settles once the expression is ready:

const currentUser = await resolve(() => user());

One correction to a thing you’ll see repeated: there is no createAsync in core 2.0. createAsync is a @solidjs/router API, and its 2.0 shape isn’t settled in the sources I could find, so don’t reach for it as the core data primitive. The core primitive is the async createMemo above.

What replaced <Suspense>? The <Loading> boundary

<Loading> is the direct replacement for 1.x’s <Suspense>, and the semantics are the reason the release was titled “The <Suspense> is Over.” <Loading> covers initial readiness only. It shows its fallback while the subtree has nothing to render yet, and once real content is on screen it gets out of the way.

<Loading fallback={<Spinner />}>
  <Profile user={user()} />
</Loading>

This is the behavior React’s Suspense makes you fight for. In React, when a value a Suspense boundary depends on changes, the boundary can rip the rendered content down and flash the fallback again unless you wrap the update in useTransition. Solid inverts the default: after the first paint, a refetch holds the old content on screen. It won’t kick you back to the spinner.

When you do want the fallback to reappear on a specific change (say the whole record is being swapped, not refreshed), the new on prop opts into that:

<Loading on={userId()} fallback={<Spinner />}>
  <Profile user={user()} />
</Loading>

Now a change to userId() while data is pending re-shows the fallback; other pending work doesn’t.

How do you coordinate several boundaries? <Reveal>

<Reveal> replaces 1.x’s <SuspenseList> and controls the order in which sibling <Loading> boundaries reveal their content. It takes an order prop of "sequential" (the default), "together", or "natural":

  • sequential reveals boundaries in DOM order; later ones stay on their fallbacks until every earlier one has resolved.
  • together holds every fallback until the whole group is ready, then reveals at once.
  • natural lets each boundary reveal as soon as its own data lands.

There’s also a collapsed boolean, consulted only under order="sequential", that renders a single frontier fallback instead of one per boundary. The control-flow RFC is the only source documenting the fine-grained nesting rules here, so treat the exact <Reveal> semantics as provisional until stable.

How do you show a refresh without a spinner? isPending

isPending reports that a change to a specific read is in flight, so you can show a subtle indicator while the stale content stays put. It takes a thunk, and it actually performs the read, so where you place it matters.

const refreshing = () => isPending(() => user());

<Loading fallback={<Spinner />}>
  <Show when={refreshing()}>
    <RefreshBar />
  </Show>
  <Profile user={user()} />
</Loading>;

First load hits <Loading> and shows the spinner. A later refetch keeps <Profile> visible and flips refreshing() to true, so you render a thin bar instead of tearing the page down. Stale-while-revalidate comes built into the primitive, so you don’t assemble it yourself.

The gotcha that will catch you: a bare refresh() reads as not pending. Re-asking the same question (a poll, a manual refresh, a confirming refetch after a mutation) is treated as silent by design. If you want that reload to register as pending, you declare it with affects:

// Silent: isPending stays false, no refresh UI
refresh(user);

// Declared: now the refresh reads as pending
affects(user);
refresh(user);

affects is one of the newer, rougher corners of the API (it lives in a single RFC family and is likely to move), but the underlying rule is worth knowing now: pending is about a changed input, not about work happening. There’s also a latest(fn) helper that peeks at the in-flight value during a transition and falls back to stale if the next value isn’t ready, for when you want to show the incoming id before its data lands.

Where do mutations live now? action and optimistic updates

Solid 2.0 gives writes a home in core with action, and the shape is unusual: action wraps a generator. Each yield is a point where the action awaits, which lets the reactive system track an optimistic value until the real one lands.

const [todos, setTodos] = createOptimisticStore(() => api.getTodos(), []);

const addTodo = action(function* (todo) {
  setTodos((t) => {
    t.push(todo); // optimistic: show it immediately
  });
  yield api.addTodo(todo); // await the server
  refresh(todos); // reconcile with the source of truth
});

createOptimistic has the same surface as createSignal, but its writes are optimistic: they can be overridden during a transition and revert when the transition completes. createOptimisticStore is the store version. The RFCs document it as createOptimisticStore(fnOrValue, seed, options?) while the migration guide shows a single-object form, so the exact arity is still settling; the two-argument derived form above is the one that’s fully specified.

refresh(x) asks a derived signal or store to recompute. It’s imperative revalidation, not a piece of UI state, which is why the affects dance above exists for the cases where you want a refresh to be visible. An async-generator form of action also works, where a bare yield; resumes the action in the same transition context after an await.

If you’ve used React 19’s Actions and useOptimistic, the intent is familiar. The difference is that Solid folds the optimistic write, the server call, and the revalidation into one generator instead of splitting them across a hook and an action.

Why is batching deterministic now?

Solid 2.0 batches updates on a microtask by default, so a setter queues the write and reads don’t reflect it until the batch flushes. batch() is removed; flush() is how you apply pending updates synchronously when you need the result right away.

const [count, setCount] = createSignal(0);

setCount(1);
count(); // still 0: queued on the microtask
flush();
count(); // 1

I mentioned this in the first post as a gotcha; the reason it matters here is that it’s what makes async reliable. Running the tracking (compute) half of every effect before any side-effecting half gives the graph a complete dependency picture before anything runs, which is exactly what <Loading> and error boundaries need to decide what’s ready. flush(fn) also takes a callback and drains the writes inside it before returning, preserving the return value. Coming from React’s synchronous batching it’s an adjustment, but the model is predictable once async is threaded through everything.

What changed for transitions? They’re built in

This one’s aimed squarely at React developers: startTransition and useTransition are gone. Solid 2.0 treats transitions as a core scheduling concept, and multiple can be in flight at once. Pending UI is expressed through <Loading> and isPending rather than by wrapping updates. You don’t opt in per call site; the framework does the bookkeeping.

Here’s the async surface mapped against React 19:

Task React 19 Solid 2.0
Fetch data use() + Suspense, or a data library createMemo returning a promise
Loading UI <Suspense fallback> (can re-trigger) <Loading> (initial readiness only)
Refresh without a flash useTransition + isPending, opt-in per call isPending(fn), observe any read
Optimistic update useOptimistic createOptimistic / createOptimisticStore
Mutations Actions / form actions action(function*) + refresh
Batching automatic sync batching microtask batching + flush()

The pattern across that table: React exposes each capability as a hook you opt into, while Solid pushes the distinction between “nothing to show” and “refreshing what’s shown” down into the primitives, so you get the good behavior by default. That reactivity model is the same shift toward signals happening across the whole ecosystem.

Should you build on this yet?

Not in production. It’s beta.18, the async core (async createMemo, <Loading>, microtask batching with flush, generator actions) is stable enough to learn on, but the edges (affects, createOptimisticStore’s arity, <Reveal>’s nesting rules) are still moving. The announcement is worth reading in full in the beta.0 discussion.

What I’d actually do, and what I’m doing: build a small data-heavy screen with solid-js@next, wire up a createMemo that fetches, drop a <Loading> around it, and trigger a refetch. Watch the old content stay on screen while the new content loads. That single behavior is the clearest argument for why fine-grained reactivity was worth following all the way to async.