Skip to content

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:

Old async pattern
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:

  1. Suspense didn’t work - The loading signal was separate from Suspense
  2. No request deduplication - Multiple components calling the same fetch resulted in duplicate requests
  3. Race conditions - Rapid navigation could show stale data
  4. 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:

Using createAsync
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:

Accessing async state
const users = createAsync(() => fetchUsers());
// These are available without extra code:
users.loading // boolean - true while fetching
users.error // Error | null - set if fetch fails
users() // The data - triggers Suspense while loading

cache: Request Deduplication

The cache function wraps server functions and deduplicates identical concurrent requests:

Server function with cache
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:

Request flow without vs with cache
Without cache:
Component A calls getUser("123") → Server request 1
Component B calls getUser("123") → Server request 2 (duplicate!)
With cache:
Component A calls getUser("123") → Server request
Component B calls getUser("123") → Returns cached promise

I made the mistake of trying to mutate cached data directly:

Wrong: Mutating cache result
const user = createAsync(() => getUser("123"));
user().name = "New Name"; // WRONG - immutable!

Instead, trigger revalidation:

Correct: Revalidating cache
import { revalidate } from "@solidjs/router";
await revalidate(["getUser"]);

action: Form Submissions and Mutations

For POST/PUT/DELETE operations, use action instead of cache:

Action for mutations
import { action } from "@solidjs/router";
const deletePost = action(async (id: string) => {
"use server";
await db.posts.delete(id);
return { success: true };
}, "deletePost");
// Use in component
function PostItem({ post }) {
return (
<button
onClick={async () => {
await deletePost(post.id);
}}
>
Delete
</button>
);
}

Actions expose their state:

Action state
deletePost.pending // true while submitting
deletePost.error // Error if failed
deletePost.result // Return value on success

When to Use Each Primitive

Decision flowchart
┌─────────────────────────────────────┐
│ 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

Wrong placement
function MyComponent() {
const handleClick = () => {
// WRONG - creates new resource on every click
const data = createAsync(() => fetchData());
};
}
Correct placement
function MyComponent() {
// RIGHT - defined at component scope
const data = createAsync(() => fetchData());
const handleClick = () => {
// Access the resource
console.log(data());
};
}

Mistake 2: Missing Suspense Boundary

Will throw if no Suspense
function UsersList() {
const users = createAsync(() => fetchUsers());
// CRASHES - accessing users() without Suspense
return <div>{users().length} users</div>;
}

Always wrap:

Proper Suspense boundary
function UsersList() {
const users = createAsync(() => fetchUsers());
return (
<Suspense fallback={<UsersSkeleton />}>
<div>{users().length} users</div>
</Suspense>
);
}

Mistake 3: Using action for GET Requests

Wrong primitive choice
// WRONG - action is for mutations
const getUser = action(async (id: string) => {
"use server";
return db.users.find(id);
});
// RIGHT - cache for data fetching
const getUser = cache(async (id: string) => {
"use server";
return db.users.find(id);
}, "getUser");

Complete Example

Here’s a full component using all three primitives:

server.ts
"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");
Posts.tsx
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:

Installation
npm install solid-js@next @solidjs/router@next

The primitives are available from:

Imports
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