Skip to content

How Does Async Handling Work in SolidJS 2.0? A Complete Guide to createAsync and Async Primitives

I spent an entire afternoon debugging why my loading spinner wouldn’t show up. The data was fetching fine, but the UI just… sat there. Blank. No error, no loading state, nothing.

BrokenComponent.tsx
function UserProfile() {
const [user, setUser] = createSignal(null);
const [loading, setLoading] = createSignal(true);
onMount(async () => {
const response = await fetch('/api/user');
setUser(await response.json());
setLoading(false);
});
// This never shows the loading state properly
return (
<Show when={!loading()} fallback={<Spinner />}>
<div>{user()?.name}</div>
</Show>
);
}

The code looked right. But SolidJS’s reactivity doesn’t work the way I thought it did. That’s when I realized: I was fighting the framework instead of using its tools.

The Async State Management Problem

Every frontend developer knows the drill. You need to fetch data, so you:

  1. Create a loading state
  2. Create an error state
  3. Create a data state
  4. Write a fetchData function
  5. Handle the try/catch
  6. Remember to set loading to false in a finally block
  7. Hope you didn’t forget cleanup on unmount
TheUsualBoilerplate.tsx
function TypicalDataFetching() {
const [data, setData] = createSignal(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal(null);
onMount(async () => {
try {
setLoading(true);
const response = await fetch('/api/data');
setData(await response.json());
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
});
return (
<Switch>
<Match when={loading()}><Spinner /></Match>
<Match when={error()}><Error error={error()} /></Match>
<Match when={data()}><DataView data={data()} /></Match>
</Switch>
);
}

Repeat this for every component that needs data. Now multiply by the number of components in your app. That’s a lot of boilerplate.

I tried React Query. It helped, but then I had Suspense boundaries that wouldn’t cooperate. The loading states would flash. Error boundaries would catch things they shouldn’t. It felt like duct tape on top of duct tape.

Then SolidJS 2.0 beta dropped. The release notes mentioned something that caught my eye:

“async being a first class citizen”

SolidJS 2.0’s Answer: createAsync

The createAsync primitive changed how I think about async data. Instead of managing states manually, I just… declare what I want:

SimpleCreateAsync.tsx
import { createAsync } from "@solidjs/router";
import { Suspense } from "solid-js";
function UserProfile() {
const user = createAsync(async () => {
const response = await fetch('/api/user');
return response.json();
});
return (
<Suspense fallback={<Spinner />}>
<div>{user()?.name}</div>
</Suspense>
);
}

That’s it. No manual loading states. No error handling boilerplate. The Suspense boundary automatically shows the fallback while the promise is pending.

How It Actually Works

I was skeptical. “Where’s the magic?” I thought. So I dug into the mechanics.

createAsync returns a signal that:

  1. Starts as undefined (pending)
  2. Resolves to the fetched value
  3. Throws to the nearest Suspense boundary when pending
  4. Integrates with SolidJS’s fine-grained reactivity
┌─────────────────────────────────────────────────────────────┐
│ Suspense Boundary │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ createAsync() ││
│ │ ││
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ││
│ │ │ PENDING │───▶│ LOADING │───▶│ RESOLVED │ ││
│ │ └─────────┘ └─────────┘ └─────────┘ ││
│ │ │ │ │ ││
│ │ ▼ ▼ ▼ ││
│ │ [throws] [Suspense [returns ││
│ │ to shows data] ││
│ │ Suspense] fallback] ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘

The first time I ran it, I forgot the Suspense boundary. Big mistake:

ForgotSuspense.tsx
function BrokenProfile() {
const user = createAsync(async () => {
const response = await fetch('/api/user');
return response.json();
});
// This throws an error when user() is called during pending state
return <div>{user().name}</div>;
// Uncaught Error: Suspense boundary not found
}

SolidJS throws when you try to access a pending async value outside a Suspense boundary. This is by design - it forces you to handle the loading state properly.

The Cache Primitive: Smart Data Management

Next, I tried to fetch user data in multiple components. Same user, same data, but multiple requests:

WastefulFetching.tsx
function Header() {
const user = createAsync(() => fetchUser()); // Request 1
return <nav>{user()?.name}</nav>;
}
function Sidebar() {
const user = createAsync(() => fetchUser()); // Request 2 (duplicate!)
return <aside>{user()?.email}</aside>;
}
function Footer() {
const user = createAsync(() => fetchUser()); // Request 3 (duplicate!)
return <footer>{user()?.id}</footer>;
}

Three network requests for the same data. Wasteful.

The cache primitive solves this:

CachedFetching.tsx
import { cache, createAsync } from "@solidjs/router";
// Define once, reuse everywhere
export const getUser = cache(async (id: string) => {
"use server";
const user = await db.users.find(id);
return user;
}, "users");
// Now all components share the same cached data
function Header() {
const user = createAsync(() => getUser(userId()));
return <nav>{user()?.name}</nav>;
}
function Sidebar() {
const user = createAsync(() => getUser(userId())); // Same cache!
return <aside>{user()?.email}</aside>;
}

The "use server" directive is crucial. I forgot it once and spent an hour wondering why my database calls were happening on the client:

WrongNoDirective.tsx
// WRONG: This runs on the client, not the server
export const getUser = cache(async (id: string) => {
const user = await db.users.find(id); // db is undefined on client!
return user;
}, "users");
// CORRECT: This runs on the server
export const getUser = cache(async (id: string) => {
"use server"; // <-- This is the key
const user = await db.users.find(id);
return user;
}, "users");

Reactive Keys: The Trap I Fell Into

Here’s where I really messed up. I had a component that needed to refetch when the user ID changed:

StaleData.tsx
function UserDashboard() {
const [userId, setUserId] = createSignal("1");
// This never updates when userId changes!
const user = createAsync(async () => {
const response = await fetch(`/api/user/${userId()}`);
return response.json();
});
return (
<div>
<button onClick={() => setUserId("2")}>Switch User</button>
{/* Still shows user 1's data! */}
<div>{user()?.name}</div>
</div>
);
}

The button click changed the signal, but createAsync didn’t know it needed to refetch. The key option fixes this:

ReactiveKey.tsx
function UserDashboard() {
const [userId, setUserId] = createSignal("1");
// Now it refetches when userId changes
const user = createAsync(
async () => {
const response = await fetch(`/api/user/${userId()}`);
return response.json();
},
{ key: userId } // <-- The reactive key
);
return (
<div>
<button onClick={() => setUserId("2")}>Switch User</button>
<Suspense fallback={<Spinner />}>
<div>{user()?.name}</div>
</Suspense>
</div>
);
}

The key option tells SolidJS: “When this signal changes, throw away the old data and fetch again.”

Actions: Handling Mutations

After getting reads working, I needed to handle form submissions. The old way was messy:

OldMutationWay.tsx
function ProfileForm() {
const [submitting, setSubmitting] = createSignal(false);
const [error, setError] = createSignal(null);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const formData = new FormData(e.target as HTMLFormElement);
await fetch('/api/profile', {
method: 'POST',
body: formData
});
} catch (e) {
setError(e);
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<button disabled={submitting()}>
{submitting() ? 'Saving...' : 'Save'}
</button>
{error() && <div>{error().message}</div>}
</form>
);
}

The action primitive simplifies this dramatically:

CleanAction.tsx
import { action } from "@solidjs/router";
const updateProfile = action(async (formData: FormData) => {
"use server";
const name = formData.get("name");
await db.users.update(userId, { name });
return { success: true };
});
function ProfileForm() {
return (
<form action={updateProfile} method="post">
<input name="name" />
<button type="submit">Save</button>
</form>
);
}

The form just works. Progressively enhanced. If JavaScript fails, it still submits as a regular form.

Error Handling: What I Got Wrong

I thought Suspense would handle errors too. It doesn’t. Errors need ErrorBoundary:

ErrorHandling.tsx
import { ErrorBoundary } from "solid-js";
function SafeProfile() {
const user = createAsync(async () => {
const response = await fetch('/api/user');
if (!response.ok) {
throw new Error('Failed to load user');
}
return response.json();
});
return (
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
<Suspense fallback={<Spinner />}>
<div>{user()?.name}</div>
</Suspense>
</ErrorBoundary>
);
}

The hierarchy matters:

┌──────────────────────────────────────────┐
│ ErrorBoundary │
│ ┌────────────────────────────────────┐ │
│ │ Suspense │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ createAsync() │ │ │
│ │ │ │ │ │
│ │ │ Pending ──▶ Suspense │ │ │
│ │ │ Resolved ──▶ Render data │ │ │
│ │ │ Rejected ──▶ ErrorBoundary│ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘

What About Manual Cache Invalidation?

After a mutation, I needed to refetch the stale data. The revalidate function handles this:

CacheInvalidation.tsx
import { action, revalidate } from "@solidjs/router";
const updateProfile = action(async (formData: FormData) => {
"use server";
const name = formData.get("name");
await db.users.update(userId, { name });
// Invalidate the user cache
revalidate("users");
return { success: true };
});

You can also revalidate everything:

RevalidateAll.tsx
// Revalidate all cache keys
revalidate();
// Revalidate specific keys
revalidate("users");
revalidate("posts");

Comparison: What React Does vs What SolidJS 2.0 Does

I’ve used both. Here’s the difference I felt:

AspectReact (use + Suspense)SolidJS 2.0 (createAsync)
Loading stateManual or experimentalAutomatic with Suspense
Error handlingErrorBoundary + manualErrorBoundary integrated
Cache managementExternal library neededBuilt-in with cache
MutationsMultiple patternsSingle action primitive
Progressive enhancementComplex setupForms work without JS
Mental modelHooks + effects + contextSignals + primitives

The Reddit thread that helped me understand this better:

“Having createAsync handle loading/error states with proper Suspense integration out of the box is something React has been struggling to nail down for years”

I felt that pain. React Query helped, but it was another layer. SolidJS 2.0 makes async a first-class concern.

The Mistake I Almost Made

I almost tried to mix manual state with createAsync:

WrongPattern.tsx
// WRONG: Don't do this
function Component() {
const [loading, setLoading] = createSignal(false);
const data = createAsync(async () => {
setLoading(true); // Unnecessary!
const result = await fetchData();
setLoading(false); // Unnecessary!
return result;
});
return (
<Show when={loading()} fallback={<Content data={data()} />}>
<Spinner />
</Show>
);
}

This defeats the purpose. Let Suspense do its job:

RightPattern.tsx
// CORRECT: Trust the primitives
function Component() {
const data = createAsync(() => fetchData());
return (
<Suspense fallback={<Spinner />}>
<Content data={data()} />
</Suspense>
);
}

When to Use Each Primitive

After building a few components, I settled on this mental model:

  1. createAsync - For any async data fetch that needs reactivity
  2. cache - When multiple components need the same data
  3. action - For form submissions and mutations
  4. revalidate - After mutations to refresh stale data
CompleteExample.tsx
import { createAsync, cache, action, revalidate } from "@solidjs/router";
import { Suspense, ErrorBoundary } from "solid-js";
// 1. Define cached server functions
export const getUser = cache(async (id: string) => {
"use server";
return await db.users.find(id);
}, "users");
export const updateBio = action(async (formData: FormData) => {
"use server";
const bio = formData.get("bio");
await db.users.update(userId, { bio });
revalidate("users");
return { success: true };
});
// 2. Use in components
function UserBio({ userId }: { userId: string }) {
const user = createAsync(() => getUser(userId));
return (
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
<Suspense fallback={<div>Loading...</div>}>
<form action={updateBio} method="post">
<textarea name="bio" value={user()?.bio} />
<button type="submit">Update</button>
</form>
</Suspense>
</ErrorBoundary>
);
}

What I Wish I Knew Earlier

  1. Always wrap createAsync in Suspense - It throws during pending state
  2. Use the key option for reactive fetches - Otherwise you get stale data
  3. Don’t forget “use server” - Your server functions won’t work without it
  4. Use ErrorBoundary for errors - Suspense only handles loading, not errors
  5. Revalidate after mutations - Otherwise you see stale cached data

SolidJS 2.0’s async primitives made my code significantly cleaner. The first-class async support means I spend less time on boilerplate and more time on actual features. The patterns are consistent, the mental model is simple, and it just works.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments