What Are the New Primitives in SolidJS 2.0? A Complete Guide to createAsync, cache, and action
I spent hours debugging why my SolidJS app kept flickering on every route change. The culprit? Manual async state management that fought against Suspense boundaries. Then SolidJS 2.0 beta dropped, and the new async primitives made me realize I’d been doing it wrong the whole time.
The Problem: Async Operations Were Verbose
Before SolidJS 2.0, handling async data meant juggling multiple signals:
const [data, setData] = createSignal<User[] | null>(null);const [loading, setLoading] = createSignal(true);const [error, setError] = createSignal<Error | null>(null);
onMount(async () => { try { const result = await fetchUsers(); setData(result); } catch (e) { setError(e as Error); } finally { setLoading(false); }});This pattern had issues:
- Suspense didn’t work - The loading signal was separate from Suspense
- No request deduplication - Multiple components calling the same fetch resulted in duplicate requests
- Race conditions - Rapid navigation could show stale data
- Server function debugging - Errors in
"use server"functions were opaque
The Solution: Three New Primitives
SolidJS 2.0 introduces three focused primitives that replace the verbose patterns above.
createAsync: Reactive Async with Suspense
createAsync wraps an async function and returns a resource signal that integrates with Suspense automatically:
import { createAsync } from "@solidjs/core";import { Suspense } from "solid-js";
function UsersList() { const users = createAsync(() => fetchUsers());
return ( <Suspense fallback={<div>Loading users...</div>}> <ul> <For each={users()}>{user => <li>{user.name}</li>}</For> </ul> </Suspense> );}No manual loading states. No try-catch in components. The Suspense boundary handles everything.
I tried accessing the loading state directly for a progress indicator:
const users = createAsync(() => fetchUsers());
// These are available without extra code:users.loading // boolean - true while fetchingusers.error // Error | null - set if fetch failsusers() // The data - triggers Suspense while loadingcache: Request Deduplication
The cache function wraps server functions and deduplicates identical concurrent requests:
import { cache } from "@solidjs/router";
export const getUser = cache(async (id: string) => { "use server"; return db.users.find(id);}, "getUser");When multiple components request the same user simultaneously, only one server call happens:
Without cache:Component A calls getUser("123") → Server request 1Component B calls getUser("123") → Server request 2 (duplicate!)
With cache:Component A calls getUser("123") → Server requestComponent B calls getUser("123") → Returns cached promiseI made the mistake of trying to mutate cached data directly:
const user = createAsync(() => getUser("123"));user().name = "New Name"; // WRONG - immutable!Instead, trigger revalidation:
import { revalidate } from "@solidjs/router";
await revalidate(["getUser"]);action: Form Submissions and Mutations
For POST/PUT/DELETE operations, use action instead of cache:
import { action } from "@solidjs/router";
const deletePost = action(async (id: string) => { "use server"; await db.posts.delete(id); return { success: true };}, "deletePost");
// Use in componentfunction PostItem({ post }) { return ( <button onClick={async () => { await deletePost(post.id); }} > Delete </button> );}Actions expose their state:
deletePost.pending // true while submittingdeletePost.error // Error if faileddeletePost.result // Return value on successWhen to Use Each Primitive
┌─────────────────────────────────────┐│ What operation are you performing? │└─────────────────┬───────────────────┘ │ ┌─────────┴─────────┐ │ │ ┌───▼───┐ ┌───▼───┐ │ GET │ │ POST │ └───┬───┘ │ PUT │ │ │ DELETE│ │ └───┬───┘ │ │ ┌────▼────┐ ┌─────▼─────┐ │ cache │ │ action │ │(dedupe) │ │(mutation) │ └─────────┘ └───────────┘ │ │ └───────┬───────────┘ │ ┌─────▼─────┐ │createAsync│ │(reactive) │ └───────────┘Common Mistakes I Made
Mistake 1: createAsync Inside Event Handlers
function MyComponent() { const handleClick = () => { // WRONG - creates new resource on every click const data = createAsync(() => fetchData()); };}function MyComponent() { // RIGHT - defined at component scope const data = createAsync(() => fetchData());
const handleClick = () => { // Access the resource console.log(data()); };}Mistake 2: Missing Suspense Boundary
function UsersList() { const users = createAsync(() => fetchUsers());
// CRASHES - accessing users() without Suspense return <div>{users().length} users</div>;}Always wrap:
function UsersList() { const users = createAsync(() => fetchUsers());
return ( <Suspense fallback={<UsersSkeleton />}> <div>{users().length} users</div> </Suspense> );}Mistake 3: Using action for GET Requests
// WRONG - action is for mutationsconst getUser = action(async (id: string) => { "use server"; return db.users.find(id);});
// RIGHT - cache for data fetchingconst getUser = cache(async (id: string) => { "use server"; return db.users.find(id);}, "getUser");Complete Example
Here’s a full component using all three primitives:
"use server";import { cache, action } from "@solidjs/router";import { db } from "./db";
export const getPosts = cache(async () => { return db.posts.findAll();}, "posts");
export const deletePost = action(async (id: string) => { await db.posts.delete(id); return { success: true };}, "deletePost");import { createAsync } from "@solidjs/core";import { Suspense, ErrorBoundary, For } from "solid-js";import { getPosts, deletePost } from "./server";
export function Posts() { const posts = createAsync(() => getPosts());
return ( <Suspense fallback={<PostsSkeleton />}> <ErrorBoundary fallback={(err) => <PostsError error={err} />}> <ul> <For each={posts()}> {post => ( <li> {post.title} <button onClick={() => deletePost(post.id)}> Delete </button> </li> )} </For> </ul> </ErrorBoundary> </Suspense> );}Why This Matters
The Reddit discussion on r/javascript highlighted what makes these primitives different from other frameworks:
“The new cache and action primitives for SolidStart feel like they learned from the DX pain points of other meta-frameworks — you get server functions without the magic that makes debugging a nightmare”
Key improvements:
- Smaller API surface - Three focused primitives vs React’s useEffect + useState + useCallback
- Better debugging - Actions are explicit, traceable server function calls
- Suspense-first - Async primitives integrate with Suspense by default
- Type safety - Full TypeScript inference without manual type annotations
Getting Started
Install SolidJS 2.0 beta:
npm install solid-js@next @solidjs/router@nextThe primitives are available from:
import { createAsync } from "@solidjs/core";import { cache, action, revalidate } from "@solidjs/router";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