Skip to main content

Zustand Async: Data Fetching and Middleware

Zustand handles async operations differently from Redux. There is no built-in middleware like Redux Thunk; instead, you write async logic directly in actions or use custom middleware. This article covers the two most common patterns: async actions and the immer middleware for immutable updates.

I spent six months using Redux Thunk before switching to Zustand. The direct async-in-actions pattern was simpler and required far less boilerplate.

Pattern 1: Async Logic Inside Actions

The simplest pattern is to write async functions directly as store actions. Zustand does not care if an action is sync or async:

import { create } from 'zustand';

export const useUserStore = create((set) => ({
users: [],
loading: false,
error: null,

fetchUsers: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
const data = await response.json();
set({ users: data, loading: false });
} catch (err) {
set({ error: err.message, loading: false });
}
},
}));

When you call useUserStore.getState().fetchUsers() or use it in a component, the async action updates the store in stages: loading → success or error. Components that select users, loading, or error re-render as state changes.

Use this pattern in a component:

import { useEffect } from 'react';
import { useUserStore } from './userStore';

export function UserList() {
const users = useUserStore((state) => state.users);
const loading = useUserStore((state) => state.loading);
const error = useUserStore((state) => state.error);
const fetchUsers = useUserStore((state) => state.fetchUsers);

useEffect(() => {
fetchUsers();
}, [fetchUsers]);

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

This is straightforward: fetch on mount, show loading, then show data or error.

Pattern 2: Immer Middleware for Immutable Updates

The immer middleware lets you write mutations as if mutating directly, but Zustand keeps them immutable under the hood. This is useful for complex state updates:

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

export const useTodoStore = create(
immer((set) => ({
todos: [],

addTodo: (text) => set((state) => {
state.todos.push({ id: Date.now(), text, done: false });
}),

toggleTodo: (id) => set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
}),

removeTodo: (id) => set((state) => {
state.todos = state.todos.filter((t) => t.id !== id);
}),
}))
);

With immer, you mutate the draft state directly (state.todos.push(...)). Zustand converts this into an immutable update internally. Without immer, you would write set((state) => ({ todos: [...state.todos, newTodo] })), which is verbose for deep structures.

Pattern 3: Custom Middleware for Logging

Create middleware that logs all state changes:

import { create } from 'zustand';

const logger = (config) => (set, get, api) => {
return config(
(args) => {
console.log('Setting state:', args);
set(args);
console.log('New state:', get());
},
get,
api
);
};

export const useStore = create(
logger((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);

Middleware wraps the store config and intercepts set() calls. This is where you add logging, persistence, or other side effects.

Combining Multiple Middlewares

Chain middlewares using nested functions:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

export const useStore = create(
persist(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
state.todos.push({ text, done: false });
}),
})),
{ name: 'todo-store' }
)
);

Here, persist wraps immer, which wraps your store. Each middleware layer adds functionality.

Key Takeaways

  • Zustand actions can be async. Write async/await directly in actions; update state in stages (loading → success/error).
  • Load state (loading, error, data) into the store so all components can respond to fetch states.
  • The immer middleware simplifies complex mutations by letting you mutate a draft state, avoiding verbose immutable updates.
  • Custom middleware intercepts set() calls for logging, persisting, or other side effects.
  • Combine middlewares by nesting them. Order matters: middleware closer to the config function runs first.

Frequently Asked Questions

Should I fetch data in the store or in useEffect?

Ideally, call fetch from the store action (in useEffect if needed). This keeps data logic centralized and testable. If the same data is needed in multiple components, fetching from the store avoids duplicate requests.

How do I prevent duplicate fetch requests?

Track a fetching boolean in the store. Check it before calling fetchUsers(): if (!fetching) fetchUsers(). Or use a library like react-query (recommended for complex data scenarios) to handle caching and deduplication.

Can I use async/await in a selector?

No, selectors must be pure and synchronous. Wrap async logic in store actions instead.

Does immer add significant bundle size?

Immer is about 11 KB minified. If you are not using complex nested updates, skip immer and write immutable updates manually to save bundle size.

Further Reading