Learning Zustand - Actions & Async State
Episode 6 of 23

Learning Zustand - Actions & Async State

This episode covers placing logic inside the store as actions, async actions with async/await, managing loading/error/success status, and centralized error handling for clean, testable data fetching.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

A good store holds not just data, but also the way that data changes. Episode 6 covers two things: placing update logic inside the store as actions, and handling asynchronous state — loading, error, and success — with a consistent, centralized pattern.

Real applications almost always communicate with a server. The async state patterns you master in this episode will be used over and over, both directly in stores and integrated with TanStack Query in episode 12.

Actions in Stores

Putting Logic Inside the Store

Move update logic from components into store actions. Components simply call actions instead of rewriting business rules:

JSActions inside the store
type TodoState = {
  todos: string[]
  addTodo: (title: string) => void
  removeTodo: (id: string) => void
  toggleTodo: (id: string) => void
}
 
export const useTodo = create<TodoState>((set, get) => ({
  todos: [],
  addTodo: (title) =>
    set((s) => ({ todos: [...s.todos, { id: crypto.randomUUID(), title, done: false }] })),
  removeTodo: (id) =>
    set((s) => ({ todos: s.todos.filter((t) => t.id !== id) })),
  toggleTodo: (id) =>
    set((s) => ({
      todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    })),
}))

addTodo(title) hides the detail of creating the todo object inside the store, so the component stays lean and the logic can be tested without a UI.

Updating from Event Handlers

In the component, actions are used directly in event handlers:

JSActions in event handlers
const addTodo = useTodo((s) => s.addTodo)
 
function handleSubmit(e: FormEvent) {
  e.preventDefault()
  addTodo(input)
}

const addTodo = useTodo((s) => s.addTodo) fetches the action function as a selector — functions are stable, so they don't trigger extra re-renders.

Async State and Data Fetching

Async Actions with async/await

Actions can be asynchronous. The most common pattern: set the loading status, perform the fetch, then set data or error:

JSAsync action for fetching data
type UserState = {
  user: User | null
  status: 'idle' | 'loading' | 'success' | 'error'
  error: string | null
  fetchUser: (id: string) => Promise<void>
}
 
export const useUser = create<UserState>((set) => ({
  user: null,
  status: 'idle',
  error: null,
  fetchUser: async (id) => {
    set({ status: 'loading', error: null })
    try {
      const res = await fetch(`/api/users/${id}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const user = await res.json()
      set({ user, status: 'success' })
    } catch (err) {
      set({ status: 'error', error: (err as Error).message })
    }
  },
}))

fetchUser(id) runs in three steps: set loading, await fetch, then set success or error. The status string makes it easy for the component to draw different UI states.

loading/error/success Status in Components

Use the store's status for conditional rendering:

JSRendering based on status
const { user, status, error, fetchUser } = useUser(
  useShallow((s) => ({
    user: s.user,
    status: s.status,
    error: s.error,
    fetchUser: s.fetchUser,
  })),
)
 
if (status === 'loading') return <p>Memuat...</p>
if (status === 'error') return <p>Gagal: {error}</p>
if (!user) return <button onClick={() => fetchUser('1')}>Muat profil</button>
return <p>Halo, {user.name}</p>

useShallow(...) combines several fields in a single call without triggering excessive re-renders — the pattern from episode 5. The UI now simply draws according to the state status.

Centralized Error Handling

One Error Pattern for All Actions

Centralize error handling so every action follows the same rules:

JSAsync status helper
const withStatus = async <T,>(set, promise: Promise<T>): Promise<T> => {
  set({ status: 'loading', error: null })
  try {
    const data = await promise
    set({ status: 'success' })
    return data
  } catch (err) {
    set({ status: 'error', error: (err as Error).message })
    throw err
  }
}
 
fetchUser: async (id) => {
  const user = await withStatus(set, fetch(`/api/users/${id}`).then((r) => r.json()))
  set({ user })
},

The helper function withStatus(set, promise) normalizes the loading/error cycle in one place. Errors are still re-thrown so the caller can handle other side effects, for example notifications or logging to an external service.

Don't Forget to Cancel Stale Requests

Use AbortController for requests that are no longer relevant, for example when a component unmounts:

JSAbort unnecessary requests
const controller = new AbortController()
fetch('/api/users/1', { signal: controller.signal })

controller.signal is passed to fetch; calling controller.abort() stops the request. The details of this pattern are discussed in episode 13 together with transient updates.

Closing

Episode 6 teaches you that a good store holds data and behavior at once: actions contain update logic, async actions manage loading/error/success, and centralized error handling keeps stores consistent and easy to test.

Key takeaways:

  • Put update logic in store actions, not in components.
  • Async actions use async/await with idle/loading/success/error status.
  • Render the UI conditionally based on status for loading, error, and data.
  • Centralize error handling in one helper so all actions stay consistent.
  • Always reset error when starting a new request.
  • AbortController prevents state updates from stale requests.

In the next episode we will discuss TypeScript and typing storescreate<State>()(...) with generics, defining state and action interfaces, typing middleware, and the combine pattern for slicing large stores. TypeScript is your shield in a real codebase.