Zustand 05 ⚛️ Async Actions — Handling asynchronous operations & API calls
Zustand does not have a built-in concept of async actions. An action is a function in the store, and a function can be async. That is the entire mechanism. The action calls await on a promise, then calls set with the result. Zustand does not intercept the promise, does not track its state, and does not provide a loading boolean or an error object. Those must be part of the store’s state shape, written by the action itself at each stage of the request.
This is a deliberate design difference from libraries that model requests as first-class objects. TanStack Query, covered elsewhere in this series, treats a request as a query with its own lifecycle. Zustand treats a request as an action that happens to be asynchronous. The action is responsible for setting loading: true before the request, loading: false and data after success, and loading: false and error after failure. The store is a plain object, and the action is a plain async function that writes to it.
Key point: An async action must set loading state before the first await and clear it in a finally block. If the error path does not clear loading, the UI remains in a permanent loading state after a failure. If concurrent calls are possible, a simple boolean is insufficient—track a request ID or an abort controller to prevent a stale response from overwriting a newer one.
Why async actions are part of the store, not a separate layer
The co-location problem.
An async action has three responsibilities: initiate the request, track its progress, and apply the result. If these are split across a component, a hook, and the store, the state can diverge. A component might set a local loading flag that the store does not know about, and a second component reading the same store cannot display the loading state because it is trapped in the first component’s local state. Co-locating the async logic in the store means every component that reads loading, error, or data reads the same values, updated by the same action.
The stale-response problem.
A user types in a search box. The first request is sent for "a", the second for "ab", the third for "abc". The requests complete out of order: "abc" returns first, then "ab", then "a". If each action calls set({ results }) when it resolves, the final state shows the results for "a", not "abc". The store has no built-in mechanism to discard stale responses. The action must implement that mechanism itself, either by tracking a request ID, by comparing the current query to the query that initiated the request, or by using AbortController to cancel earlier requests.
The error-handling problem.
A rejected promise in an async action propagates to the caller unless it is caught. If the action is called from a component’s event handler, the rejection may become an unhandled promise rejection. The action must wrap the request in try/catch/finally and write the error to the store. The finally block is essential: it runs whether the request succeeded or failed, which is where loading must be cleared. Without finally, the error path leaves loading set to true.
a. The basic async action pattern
An async action is an async function in the store that calls set before, during, and after the request. The store’s state shape includes the fields that represent the request lifecycle.
import { create } from 'zustand'
interface User {
id: number
name: string
}
interface UserState {
user: User | null
loading: boolean
error: string | null
fetchUser: (id: number) => Promise<void>
}
export const useUserStore = create<UserState>()((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
// Set loading before the request
set({ loading: true, error: null })
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const user: User = await response.json()
// Success: set data and clear loading
set({ user, loading: false })
} catch (error) {
// Failure: set error and clear loading
set({
error: error instanceof Error ? error.message : 'Unknown error',
loading: false,
})
}
},
}))
The loading flag is set to true before the first await. The error is cleared at the same time, so a retry does not show the previous error while the new request is in flight. On success, user is set and loading is cleared. On failure, error is set and loading is cleared. The finally block is not used here because both the success and failure paths set loading: false explicitly. A finally block would be cleaner and would guarantee that loading is cleared even if the success path throws after the set call.
// Cleaner version with finally
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
set({ user: await response.json() })
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Unknown error' })
} finally {
set({ loading: false })
}
},
The finally block runs after both the try and the catch. It is the correct place to clear loading, because it executes regardless of which path was taken.
b. Preventing stale responses
When an action can be called multiple times in quick succession, the last response to arrive is not necessarily the last request that was sent. The store must be protected from out-of-order completion.
The simplest approach is to compare the current request’s input with the store’s current input before applying the result. If the input has changed, the response is stale and should be discarded.
interface SearchState {
query: string
results: string[]
loading: boolean
search: (query: string) => Promise<void>
}
export const useSearchStore = create<SearchState>()((set, get) => ({
query: '',
results: [],
loading: false,
search: async (query) => {
// Record the query that initiated this request
set({ query, loading: true })
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
const results = await response.json()
// Discard the result if the query has changed since the request started
if (get().query !== query) return
set({ results })
} catch (error) {
if (get().query !== query) return
set({ results: [] })
} finally {
// Only clear loading if this is still the current request
if (get().query === query) set({ loading: false })
}
},
}))
The get().query !== query check is the guard. When the request for "a" resolves after the request for "abc", the store’s query is "abc", so the check fails and the result for "a" is discarded. The finally block also checks, so the loading flag is not cleared by a stale request while a newer request is still in flight.
A more robust approach uses AbortController to cancel earlier requests. The store holds the current controller, and a new request aborts the previous one before starting.
let currentController: AbortController | null = null
search: async (query) => {
// Cancel the previous request if it is still running
currentController?.abort()
currentController = new AbortController()
set({ query, loading: true })
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal,
})
set({ results: await response.json() })
} catch (error) {
// AbortError is expected when a newer request cancels this one
if (error instanceof DOMException && error.name === 'AbortError') return
set({ results: [] })
} finally {
set({ loading: false })
}
},
The abort controller is stored at module scope rather than in the store because it is not state that the UI needs to read. The AbortError is caught and ignored because it represents a cancellation, not a failure. The finally block clears loading, which is correct because the aborted request is no longer running and the newer request has its own loading state.
c. Optimistic updates and rollback
An optimistic update applies the expected result to the store before the request completes, then rolls back if the request fails. This makes the UI feel instant, at the cost of complexity in the error path.
interface TodoState {
todos: { id: number; text: string; done: boolean }[]
toggleTodo: (id: number) => Promise<void>
}
export const useTodoStore = create<TodoState>()((set, get) => ({
todos: [],
toggleTodo: async (id) => {
// Capture the previous state for rollback
const previous = get().todos
// Optimistically toggle the todo
set({
todos: previous.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
})
try {
const response = await fetch(`/api/todos/${id}/toggle`, { method: 'POST' })
if (!response.ok) throw new Error('Toggle failed')
// Success: the optimistic update is confirmed
} catch (error) {
// Failure: roll back to the captured state
set({ todos: previous })
}
},
}))
The previous variable captures the array before the optimistic update. If the request fails, set({ todos: previous }) restores the array by reference. This is correct because the optimistic update created a new array, and the previous reference is still valid. The rollback is not a merge; it is a full replacement of the todos key with the captured reference.
The optimistic pattern should be used only when the request is likely to succeed and the rollback is visually acceptable. For operations that can fail for reasons the user cannot fix—a permissions error, a server-side validation failure—the non-optimistic pattern is simpler and less confusing.
Complete Example Session
// ============================================
// PART 1: THE STATE SHAPE
// ============================================
import { create } from 'zustand'
interface Post { id: number; title: string; body: string }
interface PostState {
posts: Post[]
loading: boolean
error: string | null
fetchPosts: () => Promise<void>
createPost: (title: string) => Promise<void>
deletePost: (id: number) => Promise<void>
}
// ============================================
// PART 2: THE STORE
// ============================================
export const usePostStore = create<PostState>()((set, get) => ({
posts: [],
loading: false,
error: null,
// ============================================
// PART 3: FETCH — READ OPERATION
// ============================================
fetchPosts: async () => {
set({ loading: true, error: null })
try {
const response = await fetch('/api/posts')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const posts: Post[] = await response.json()
set({ posts })
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Unknown error' })
} finally {
set({ loading: false })
}
},
// ============================================
// PART 4: CREATE — WRITE WITH OPTIMISTIC UPDATE
// ============================================
createPost: async (title) => {
// Create a temporary post with a negative ID
const tempPost: Post = { id: -Date.now(), title, body: '' }
const previous = get().posts
// Optimistically add the post
set({ posts: [...previous, tempPost], error: null })
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const created: Post = await response.json()
// Replace the temporary post with the server response
set({
posts: get().posts.map((p) => (p.id === tempPost.id ? created : p)),
})
} catch (error) {
// Roll back on failure
set({
posts: previous,
error: error instanceof Error ? error.message : 'Create failed',
})
}
},
// ============================================
// PART 5: DELETE — OPTIMISTIC REMOVE
// ============================================
deletePost: async (id) => {
const previous = get().posts
// Optimistically remove the post
set({ posts: previous.filter((p) => p.id !== id), error: null })
try {
const response = await fetch(`/api/posts/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
} catch (error) {
// Roll back on failure
set({
posts: previous,
error: error instanceof Error ? error.message : 'Delete failed',
})
}
},
}))
// ============================================
// PART 6: LOADING AND ERROR SELECTORS
// ============================================
function PostList() {
const posts = usePostStore((state) => state.posts)
const loading = usePostStore((state) => state.loading)
const error = usePostStore((state) => state.error)
const fetchPosts = usePostStore((state) => state.fetchPosts)
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
if (posts.length === 0) return <p>No posts yet.</p>
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// ============================================
// PART 7: TRIGGERING THE ASYNC ACTION
// ============================================
function PostControls() {
const createPost = usePostStore((state) => state.createPost)
const fetchPosts = usePostStore((state) => state.fetchPosts)
return (
<div>
<button onClick={() => fetchPosts()}>Refresh</button>
<button onClick={() => createPost('New post')}>Create</button>
</div>
)
}
The seven parts demonstrate the full async action lifecycle: the state shape with loading and error fields, the fetch action with try/catch/finally, the optimistic create with temporary ID replacement, the optimistic delete with rollback, and the component selectors that read the lifecycle state. The optimistic create is the most complex because the temporary post must be replaced by the server’s response, not just removed.
Quick Reference
Async Action Skeleton
| Step | Code |
|---|---|
| Set loading | set({ loading: true, error: null }) |
| Await request | const response = await fetch(url) |
| Check HTTP status | if (!response.ok) throw new Error(...) |
| Apply result | set({ data }) |
| Catch error | set({ error: message }) |
| Clear loading | finally { set({ loading: false }) } |
Stale Response Prevention
| Technique | Mechanism |
|---|---|
| Query comparison | if (get().query !== query) return |
| AbortController | controller.abort() before new request |
| Request ID | Track incrementing ID; compare on resolve |
Optimistic Update Pattern
| Phase | Action |
|---|---|
| Capture | const previous = get().state |
| Apply | set({ state: optimisticValue }) |
| Confirm | Replace optimistic value with server response |
| Rollback | set({ state: previous }) on failure |
Best Practices
✅ Do This:
// Set loading before the first await
set({ loading: true, error: null }) // ✅
// Clear loading in a finally block
finally { set({ loading: false }) } // ✅
// Check response.ok before parsing
if (!response.ok) throw new Error(`HTTP ${response.status}`) // ✅
// Guard against stale responses with get()
if (get().query !== query) return // ✅
// Capture previous state before optimistic update
const previous = get().items // ✅
// Roll back on failure
set({ items: previous }) // ✅
// Use AbortController for cancellable requests
currentController?.abort() // ✅
❌ Don’t Do This:
// Forget to clear loading on error
catch (e) { set({ error: e.message }) } // loading stays true // ❌
// Assume fetch throws on 4xx/5xx
const data = await response.json() // 500 cached as success // ❌
// Apply every response without checking for staleness
set({ results }) // Stale response overwrites newer one // ❌
// Optimistic update without capturing previous state
set({ items: optimistic }) // No rollback possible // ❌
// Use a single boolean for concurrent requests
loading: false // Concurrent calls interfere with each other // ❌
// Forget to replace the optimistic temp ID
tempPost remains in list after server response // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI stuck in loading | Error path does not clear loading | Use finally block |
| Stale data displayed | Out-of-order responses applied | Compare query or abort previous |
| Error payload cached as success | fetch does not throw on 4xx/5xx | Check response.ok |
| Optimistic update not rolled back | Previous state not captured | Capture before set |
| Duplicate requests on rapid clicks | No cancellation mechanism | AbortController or disable button |
| Temp ID remains in list | Server response not applied | Replace temp ID with server object |
Real-World Examples
1. Basic fetch action
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
set({ user: await res.json() })
} catch (e) {
set({ error: e instanceof Error ? e.message : 'Unknown' })
} finally {
set({ loading: false })
}
}
2. Stale response guard
search: async (query) => {
set({ query, loading: true })
const results = await fetch(`/api/search?q=${query}`).then(r => r.json())
if (get().query !== query) return
set({ results, loading: false })
}
3. AbortController cancellation
let controller: AbortController | null = null
search: async (query) => {
controller?.abort()
controller = new AbortController()
const res = await fetch(url, { signal: controller.signal })
}
4. Optimistic toggle
toggle: async (id) => {
const previous = get().todos
set({ todos: previous.map(t => t.id === id ? { ...t, done: !t.done } : t) })
try { await fetch(...) } catch { set({ todos: previous }) }
}
5. Optimistic delete
deletePost: async (id) => {
const previous = get().posts
set({ posts: previous.filter(p => p.id !== id) })
try { await fetch(...) } catch { set({ posts: previous }) }
}
6. Optimistic create with temp ID
createPost: async (title) => {
const temp = { id: -Date.now(), title }
set({ posts: [...get().posts, temp] })
const created = await fetch(...).then(r => r.json())
set({ posts: get().posts.map(p => p.id === temp.id ? created : p) })
}
7. Retry action
retry: () => {
const { fetchPosts } = get()
fetchPosts()
}
8. Multiple concurrent actions
// Use a request ID instead of a boolean
requestId: 0,
fetch: async () => {
const id = get().requestId + 1
set({ requestId: id })
const data = await fetch(...)
if (get().requestId !== id) return
set({ data })
}
9. Error message extraction
catch (e) {
set({ error: e instanceof Error ? e.message : 'Unknown error' })
}
10. Async action in a component
const fetchUser = useUserStore((s) => s.fetchUser)
useEffect(() => { fetchUser(1) }, [fetchUser])
Visual
The Async Action Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ STATE TRANSITIONS DURING AN ASYNC ACTION │
│ │
│ Idle: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { data: null, loading: false, error: null } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ │ action called │
│ ▼ │
│ Loading: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { data: null, loading: true, error: null } │ │
│ │ set({ loading: true, error: null }) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ ▼ success ▼ failure │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ { data, loading: false, │ │ { data: null, │ │
│ │ error: null } │ │ loading: false, │ │
│ │ set({ data }) │ │ error: message } │ │
│ │ finally: loading: false │ │ finally: loading: false │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │
│ The finally block runs in both paths. │
│ Loading is never left true after the action completes. │
└──────────────────────────────────────────────────────────────┘
Stale Response Problem
┌──────────────────────────────────────────────────────────────┐
│ OUT-OF-ORDER RESPONSES OVERWRITE EACH OTHER │
│ │
│ User types: "a" → "ab" → "abc" │
│ │
│ Request "a" ──────────────────▶ resolves (slow) │
│ Request "ab" ──────────▶ resolves │
│ Request "abc" ──────▶ resolves (fast) │
│ │
│ Timeline: │
│ T=0 "a" sent │
│ T=1 "ab" sent │
│ T=2 "abc" sent │
│ T=3 "abc" resolves → set({ results: abc }) │
│ T=4 "ab" resolves → set({ results: ab }) ← WRONG │
│ T=5 "a" resolves → set({ results: a }) ← WRONG │
│ │
│ Final state shows results for "a", not "abc". │
│ │
│ Fix: compare get().query with the request's query │
│ if (get().query !== query) return │
└──────────────────────────────────────────────────────────────┘
Optimistic Update Flow
┌──────────────────────────────────────────────────────────────┐
│ OPTIMISTIC UPDATE WITH ROLLBACK │
│ │
│ Step 1: Capture previous state │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const previous = get().todos ← reference A │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Step 2: Apply optimistic update │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ set({ todos: previous.map(toggle) }) ← reference B │ │
│ │ UI shows the toggle immediately │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ ▼ success ▼ failure │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Keep reference B │ │ set({ todos: previous }) │ │
│ │ Server confirmed │ │ Restore reference A │ │
│ │ │ │ UI reverts │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Async action definition | async function inside the store |
| Lifecycle fields | loading, error, optional data |
| Loading start | Before first await |
| Loading clear | finally block |
| HTTP error check | if (!response.ok) throw |
| Stale response guard | Compare current input or use AbortController |
| Optimistic capture | const previous = get().state |
| Optimistic rollback | set({ state: previous }) |
| Temp ID replacement | Map optimistic item to server response |
| Concurrent request tracking | Request ID or AbortController |
Key takeaways:
- Zustand has no built-in async lifecycle. The action is a plain async function that writes
loading,error, anddatato the store itself. The store must include those fields in its state shape. finallyis the correct place to clearloading. The success and failure paths both need it cleared. Afinallyblock guarantees this regardless of which path was taken.fetchdoes not throw on HTTP errors. A 500 response resolves successfully. The action must checkresponse.okand throw explicitly, or the error payload is cached as valid data.- Stale responses must be discarded explicitly. The store does not know which request is the latest. Compare the current input with the input that initiated the request, or use
AbortControllerto cancel earlier requests. - Optimistic updates require capturing the previous state. The rollback is a full replacement of the changed key with the captured reference. Without the capture, there is nothing to roll back to.
- Temporary IDs must be replaced. An optimistically created item uses a temporary ID. When the server responds with the real object, the temporary item must be mapped to the server response, not left alongside it.
- A single boolean is insufficient for concurrent requests. If multiple requests can be in flight, track a request ID or abort the previous request. A boolean cannot represent which request is still active.
Remember: An async action is an action that happens to be asynchronous. Zustand does not model the request; the store holds the lifecycle state, and the action writes to it. Set loading before the await, clear it in finally, check response.ok, and guard against stale responses. For optimistic updates, capture the previous state, apply the change, and restore the capture on failure. The store is a plain object, and the async action is a plain function that writes to it at each stage of the request.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!