How to Implement Async Actions and Side Effects in Zustand
Zustand supports async actions and side effects natively by allowing store methods to use async/await and calling set after promises resolve, while subscriptions and middleware like subscribeWithSelector and persist provide structured patterns for reactive side effects and async storage coordination.
Zustand is a minimal, unopinionated state management library for React that treats your store as a plain JavaScript object. Unlike Redux or similar libraries, Zustand does not require special "thunk" middleware to handle asynchronous logic. This article explains how to implement async actions and side effects in Zustand according to the pmndrs/zustand source code.
Understanding Zustand's Core Architecture for Async Operations
The Vanilla Store Foundation
At its core, Zustand's store is built in src/vanilla.ts. The create function (or createWithEqualityFn) returns a hook that receives set, get, and the low-level api object. These helpers are pure functions that can be called from inside async functions without any extra plumbing.
Because the store is just a closure over these functions, you can define methods that perform asynchronous work—such as fetch requests, timers, or file I/O—and update state when the operation completes.
State Updates After Async Resolution
When an async action resolves, it calls set (or api.setState) just like a synchronous action. In src/vanilla.ts, set merges state shallowly by default, triggering a re-render in React components that subscribe to the affected slices. This means you don't need to wrap async logic in special middleware; the store handles updates uniformly regardless of whether they originate from synchronous or asynchronous code.
Implementing Async Actions in Zustand Stores
The idiomatic pattern for async actions in Zustand is to define an async method in your store that uses await for side effects and calls set upon completion. This pattern is documented in docs/learn/guides/beginner-typescript.md#async-actions.
// src/stores/weatherStore.ts
import { create } from 'zustand'
interface WeatherState {
temperature: number | null
loading: boolean
error: string | null
fetchWeather: (city: string) => Promise<void>
}
export const useWeatherStore = create<WeatherState>()((set) => ({
temperature: null,
loading: false,
error: null,
fetchWeather: async (city) => {
set({ loading: true, error: null })
try {
const res = await fetch(`https://api.example.com/weather?city=${city}`)
const data = await res.json()
set({ temperature: data.temp, loading: false })
} catch (e) {
set({ error: (e as Error).message, loading: false })
}
},
}))
You can trigger this action from any component or utility function:
// Trigger from outside a React component
useWeatherStore.getState().fetchWeather('Berlin')
Because fetchWeather returns a Promise, you can also await it in your UI logic to show spinners or handle sequential operations.
Handling Side Effects with Subscriptions
Zustand provides two primary patterns for side effects: direct execution inside actions and reactive subscriptions.
Direct Side Effects Inside Actions
For one-off operations—such as logging, analytics, or non-idempotent API calls—you can place the side effect directly inside the action body before or after the set call. This is the simplest approach and requires no additional middleware.
Reactive Side Effects with subscribeWithSelector
For side effects that must react to specific state changes, use the subscribeWithSelector middleware implemented in src/middleware/subscribeWithSelector.ts. This middleware adds a selector-based overload to the subscribe method, allowing you to listen to a slice of state and run effects only when that slice changes.
// src/stores/timerStore.ts
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
interface TimerState {
seconds: number
start: () => void
stop: () => void
}
export const useTimerStore = create<TimerState>()(
subscribeWithSelector((set, get) => {
let intervalId: NodeJS.Timeout | null = null
return {
seconds: 0,
start: () => {
if (!intervalId) {
intervalId = setInterval(() => set((s) => ({ seconds: s.seconds + 1 })), 1000)
}
},
stop: () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
},
}
})
)
// Subscribe to side effects elsewhere in the app
useTimerStore.subscribe(
(state) => state.seconds,
(newSec, oldSec) => {
console.log(`Timer ticked: ${oldSec} → ${newSec}`)
},
{ fireImmediately: true }
)
This pattern decouples your side effects from your React components, making them testable and reusable across the application.
Coordinating Async Storage and Hydration
When side effects involve asynchronous storage—such as localStorage, IndexedDB, or React Native's AsyncStorage—the persist middleware in src/middleware/persist.ts demonstrates the canonical pattern. This middleware uses the internal toThenable helper to normalize both synchronous and asynchronous storage APIs, and it tracks hydration state via a hydrationVersion counter to prevent race conditions.
You can apply this same pattern to any async side effect that must coordinate with the store lifecycle:
// src/stores/cartStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface CartState {
items: string[]
add: (id: string) => void
}
export const useCart = create<CartState>()(
persist(
(set) => ({
items: [],
add: (id) => set((s) => ({ items: [...s.items, id] })),
}),
{
name: 'cart-storage',
// Example: Async storage for React Native
storage: createJSONStorage(() => ({
getItem: async (key) => await AsyncStorage.getItem(key),
setItem: async (key, value) => await AsyncStorage.setItem(key, value),
removeItem: async (key) => await AsyncStorage.removeItem(key),
})),
}
)
)
The persist middleware automatically handles the async getItem call during store initialization, re-hydrating the store when the Promise resolves without requiring manual intervention.
Summary
- Zustand stores are plain objects: Async actions are simply methods that use
async/awaitand callsetwhen data arrives, as implemented insrc/vanilla.ts. - No special middleware required: Unlike Redux, Zustand does not need thunks; the
setandgethelpers work inside any async function. - Side effects have two paths: Execute one-off effects directly inside actions, or use
subscribeWithSelector(src/middleware/subscribeWithSelector.ts) for reactive side effects tied to specific state slices. - Async storage coordination: The
persistmiddleware (src/middleware/persist.ts) demonstrates how to handle async storage usingtoThenableand version counters to prevent race conditions during hydration.
Frequently Asked Questions
Can I use async/await directly in Zustand actions?
Yes. Zustand actions are regular JavaScript methods. You can mark them as async and use await for any Promise-based API. When the async work completes, call set to update the store. This works because set and get are just functions passed to your store creator in src/vanilla.ts, and they remain valid regardless of when they are called.
How do I handle loading states during async operations?
Handle loading states by setting a boolean flag before and after the async call. In your store, initialize a loading property to false. At the start of your async action, call set({ loading: true }), then perform the fetch. In a finally block or after the await, call set({ loading: false }) along with your data. This pattern ensures your UI can show spinners while data loads.
What's the difference between side effects in actions versus subscriptions?
Side effects inside actions are imperative and tied to specific user interactions or method calls, making them ideal for one-off tasks like logging or analytics. Subscriptions, particularly via subscribeWithSelector from src/middleware/subscribeWithSelector.ts, are reactive and run whenever a specific slice of state changes, regardless of what caused the change. Use subscriptions when you need to synchronize external systems (like localStorage or WebSockets) with state changes automatically.
How does the persist middleware handle async storage?
The persist middleware in src/middleware/persist.ts normalizes async storage APIs using an internal toThenable helper, which treats both synchronous and asynchronous getItem/setItem calls uniformly. It also tracks hydration state with a hydrationVersion counter to prevent race conditions if the store is modified before async rehydration completes. This allows you to use any Promise-based storage backend (like React Native's AsyncStorage or IndexedDB) without manual coordination.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →