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.
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:
- Create a
loadingstate - Create an
errorstate - Create a
datastate - Write a
fetchDatafunction - Handle the
try/catch - Remember to set loading to false in a
finallyblock - Hope you didn’t forget cleanup on unmount
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:
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:
- Starts as
undefined(pending) - Resolves to the fetched value
- Throws to the nearest Suspense boundary when pending
- 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:
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:
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:
import { cache, createAsync } from "@solidjs/router";
// Define once, reuse everywhereexport 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 datafunction 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:
// WRONG: This runs on the client, not the serverexport 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 serverexport 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:
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:
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:
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:
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:
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:
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:
// Revalidate all cache keysrevalidate();
// Revalidate specific keysrevalidate("users");revalidate("posts");Comparison: What React Does vs What SolidJS 2.0 Does
I’ve used both. Here’s the difference I felt:
| Aspect | React (use + Suspense) | SolidJS 2.0 (createAsync) |
|---|---|---|
| Loading state | Manual or experimental | Automatic with Suspense |
| Error handling | ErrorBoundary + manual | ErrorBoundary integrated |
| Cache management | External library needed | Built-in with cache |
| Mutations | Multiple patterns | Single action primitive |
| Progressive enhancement | Complex setup | Forms work without JS |
| Mental model | Hooks + effects + context | Signals + primitives |
The Reddit thread that helped me understand this better:
“Having
createAsynchandle 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:
// WRONG: Don't do thisfunction 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:
// CORRECT: Trust the primitivesfunction 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:
- createAsync - For any async data fetch that needs reactivity
- cache - When multiple components need the same data
- action - For form submissions and mutations
- revalidate - After mutations to refresh stale data
import { createAsync, cache, action, revalidate } from "@solidjs/router";import { Suspense, ErrorBoundary } from "solid-js";
// 1. Define cached server functionsexport 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 componentsfunction 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
- Always wrap createAsync in Suspense - It throws during pending state
- Use the key option for reactive fetches - Otherwise you get stale data
- Don’t forget “use server” - Your server functions won’t work without it
- Use ErrorBoundary for errors - Suspense only handles loading, not errors
- 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