| | |

Zustand 05 ⚛️ Async Actions — Handling asynchronous operations & API calls

⏱️ Estimated read: 16 min | |

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

StepCode
Set loadingset({ loading: true, error: null })
Await requestconst response = await fetch(url)
Check HTTP statusif (!response.ok) throw new Error(...)
Apply resultset({ data })
Catch errorset({ error: message })
Clear loadingfinally { set({ loading: false }) }

Stale Response Prevention

TechniqueMechanism
Query comparisonif (get().query !== query) return
AbortControllercontroller.abort() before new request
Request IDTrack incrementing ID; compare on resolve

Optimistic Update Pattern

PhaseAction
Captureconst previous = get().state
Applyset({ state: optimisticValue })
ConfirmReplace optimistic value with server response
Rollbackset({ 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

PitfallWhy It HappensFix
UI stuck in loadingError path does not clear loadingUse finally block
Stale data displayedOut-of-order responses appliedCompare query or abort previous
Error payload cached as successfetch does not throw on 4xx/5xxCheck response.ok
Optimistic update not rolled backPrevious state not capturedCapture before set
Duplicate requests on rapid clicksNo cancellation mechanismAbortController or disable button
Temp ID remains in listServer response not appliedReplace 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

ItemValue
Async action definitionasync function inside the store
Lifecycle fieldsloading, error, optional data
Loading startBefore first await
Loading clearfinally block
HTTP error checkif (!response.ok) throw
Stale response guardCompare current input or use AbortController
Optimistic captureconst previous = get().state
Optimistic rollbackset({ state: previous })
Temp ID replacementMap optimistic item to server response
Concurrent request trackingRequest ID or AbortController

Key takeaways:

  • Zustand has no built-in async lifecycle. The action is a plain async function that writes loading, error, and data to the store itself. The store must include those fields in its state shape.
  • finally is the correct place to clear loading. The success and failure paths both need it cleared. A finally block guarantees this regardless of which path was taken.
  • fetch does not throw on HTTP errors. A 500 response resolves successfully. The action must check response.ok and 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 AbortController to 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.


Was this helpful?


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!