Skip to main content
Zustand makes it easy to handle asynchronous operations like API calls, timers, and other async tasks directly in your store actions.

Basic Async Actions

Asynchronous actions in Zustand are just regular async functions:
import { create } from 'zustand'

type User = {
  id: number
  name: string
}

type UserStore = {
  user: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: number) => Promise<void>
}

const useUserStore = create<UserStore>()((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    
    try {
      const response = await fetch(`/api/users/${id}`)
      const user = await response.json()
      set({ user, loading: false })
    } catch (error) {
      set({ error: error.message, loading: false })
    }
  },
}))
No special configuration needed - just write async functions as you normally would!

Common Async Patterns

Loading States

Track loading, success, and error states:
type State = {
  data: string[] | null
  loading: boolean
  error: string | null
  fetchData: () => Promise<void>
}

const useStore = create<State>()((set) => ({
  data: null,
  loading: false,
  error: null,
  fetchData: async () => {
    set({ loading: true, error: null })
    
    try {
      const response = await fetch('/api/data')
      const data = await response.json()
      set({ data, loading: false })
    } catch (error) {
      set({ error: error.message, loading: false, data: null })
    }
  },
}))

Using the Store in Components

function UserProfile({ userId }: { userId: number }) {
  const { user, loading, error, fetchUser } = useUserStore()

  useEffect(() => {
    fetchUser(userId)
  }, [userId, fetchUser])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
  if (!user) return <div>No user found</div>

  return (
    <div>
      <h1>{user.name}</h1>
      <p>ID: {user.id}</p>
    </div>
  )
}

Multiple Concurrent Requests

Handle multiple async operations simultaneously:
type PostsStore = {
  posts: Post[]
  comments: Comment[]
  loading: {
    posts: boolean
    comments: boolean
  }
  fetchPosts: () => Promise<void>
  fetchComments: () => Promise<void>
  fetchAll: () => Promise<void>
}

const usePostsStore = create<PostsStore>()((set, get) => ({
  posts: [],
  comments: [],
  loading: {
    posts: false,
    comments: false,
  },
  fetchPosts: async () => {
    set((state) => ({
      loading: { ...state.loading, posts: true },
    }))
    
    const response = await fetch('/api/posts')
    const posts = await response.json()
    
    set((state) => ({
      posts,
      loading: { ...state.loading, posts: false },
    }))
  },
  fetchComments: async () => {
    set((state) => ({
      loading: { ...state.loading, comments: true },
    }))
    
    const response = await fetch('/api/comments')
    const comments = await response.json()
    
    set((state) => ({
      comments,
      loading: { ...state.loading, comments: false },
    }))
  },
  fetchAll: async () => {
    // Fetch both in parallel
    await Promise.all([
      get().fetchPosts(),
      get().fetchComments(),
    ])
  },
}))
Use Promise.all() to fetch multiple resources in parallel for better performance.

Canceling Async Operations

Use AbortController to cancel in-flight requests:
type SearchStore = {
  results: string[]
  loading: boolean
  search: (query: string) => Promise<void>
  abortController: AbortController | null
}

const useSearchStore = create<SearchStore>()((set, get) => ({
  results: [],
  loading: false,
  abortController: null,
  search: async (query) => {
    // Cancel previous request
    get().abortController?.abort()
    
    const controller = new AbortController()
    set({ abortController: controller, loading: true })
    
    try {
      const response = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal,
      })
      const results = await response.json()
      set({ results, loading: false })
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Request was cancelled')
      } else {
        set({ loading: false })
      }
    }
  },
}))

Debouncing and Throttling

Use a debounce utility:
// utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | null = null
  return (...args: Parameters<T>) => {
    if (timeout) clearTimeout(timeout)
    timeout = setTimeout(() => func(...args), wait)
  }
}

// store.ts
import { debounce } from './utils/debounce'

type SearchStore = {
  query: string
  results: string[]
  search: (query: string) => void
  performSearch: (query: string) => Promise<void>
}

const useSearchStore = create<SearchStore>()((set, get) => {
  const performSearch = async (query: string) => {
    const response = await fetch(`/api/search?q=${query}`)
    const results = await response.json()
    set({ results })
  }

  const debouncedSearch = debounce(performSearch, 300)

  return {
    query: '',
    results: [],
    search: (query) => {
      set({ query })
      debouncedSearch(query)
    },
    performSearch,
  }
})

Optimistic Updates

Update the UI immediately, then sync with the server:
type Todo = {
  id: number
  text: string
  done: boolean
}

type TodoStore = {
  todos: Todo[]
  toggleTodo: (id: number) => Promise<void>
}

const useTodoStore = create<TodoStore>()((set, get) => ({
  todos: [],
  toggleTodo: async (id) => {
    // Optimistically update UI
    const previousTodos = get().todos
    set({
      todos: previousTodos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
    })
    
    try {
      // Sync with server
      await fetch(`/api/todos/${id}/toggle`, { method: 'POST' })
    } catch (error) {
      // Revert on error
      set({ todos: previousTodos })
      console.error('Failed to toggle todo:', error)
    }
  },
}))
Always handle errors in optimistic updates and revert to the previous state if the request fails.

Polling

Periodically fetch data:
type PollingStore = {
  data: any | null
  intervalId: NodeJS.Timeout | null
  startPolling: () => void
  stopPolling: () => void
  fetchData: () => Promise<void>
}

const usePollingStore = create<PollingStore>()((set, get) => ({
  data: null,
  intervalId: null,
  fetchData: async () => {
    const response = await fetch('/api/data')
    const data = await response.json()
    set({ data })
  },
  startPolling: () => {
    // Fetch immediately
    get().fetchData()
    
    // Then poll every 5 seconds
    const intervalId = setInterval(() => {
      get().fetchData()
    }, 5000)
    
    set({ intervalId })
  },
  stopPolling: () => {
    const intervalId = get().intervalId
    if (intervalId) {
      clearInterval(intervalId)
      set({ intervalId: null })
    }
  },
}))

Error Handling

1

Store error state

Always include an error field in your store:
type State = {
  data: any | null
  error: string | null
  loading: boolean
}
2

Clear errors on new requests

fetchData: async () => {
  set({ loading: true, error: null }) // Clear previous error
  // ...
}
3

Handle different error types

try {
  const response = await fetch('/api/data')
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`)
  }
  
  const data = await response.json()
  set({ data, loading: false })
} catch (error) {
  if (error instanceof Error) {
    set({ error: error.message, loading: false })
  } else {
    set({ error: 'An unknown error occurred', loading: false })
  }
}

Using get() in Async Actions

The get() function allows you to read the latest state within async actions:
type Store = {
  count: number
  incrementAsync: () => Promise<void>
}

const useStore = create<Store>()((set, get) => ({
  count: 0,
  incrementAsync: async () => {
    await new Promise((resolve) => setTimeout(resolve, 1000))
    
    // Get the latest count value
    const currentCount = get().count
    set({ count: currentCount + 1 })
  },
}))
Always use get() to read state in async functions to ensure you’re working with the latest values.

Best Practices

Wrap async operations in try-catch blocks and store errors in state:
try {
  // async operation
} catch (error) {
  set({ error: error.message })
}
Track loading states to show spinners and disable buttons:
set({ loading: true })
// async operation
set({ loading: false })
Use AbortController to prevent race conditions:
const controller = new AbortController()
fetch(url, { signal: controller.signal })
For better UX, update the UI immediately and sync later:
const prev = get().data
set({ data: newData }) // Optimistic
try {
  await sync()
} catch {
  set({ data: prev }) // Revert
}

Next Steps

Vanilla Stores

Use Zustand without React

Testing

Learn how to test async actions

Build docs developers (and LLMs) love