Skip to main content

Async Actions in Zustand

Zustand stores can contain async actions that fetch data and update state — keeping server state and UI state co-located when TanStack Query isn't needed.

Basic Async Action Pattern

import { create } from 'zustand';

interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
fetchCurrentUser: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isLoading: false,
error: null,

login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});

if (!res.ok) {
const { message } = await res.json();
throw new Error(message);
}

const { user } = await res.json();
set({ user, isLoading: false });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Login failed',
isLoading: false,
});
}
},

logout: async () => {
await fetch('/api/auth/logout', { method: 'POST' });
set({ user: null });
},

fetchCurrentUser: async () => {
const { user } = get();
if (user) return; // already have user

set({ isLoading: true });
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const user = await res.json();
set({ user, isLoading: false });
} else {
set({ isLoading: false });
}
} catch {
set({ isLoading: false });
}
},
}));

Zustand vs TanStack Query

Use Zustand for async state when:

  • The state is UI-specific (auth, theme, notifications)
  • You need the data throughout the app lifecycle (not just in components)
  • There's no automatic caching/refetching needed

Use TanStack Query for async state when:

  • You're fetching server data that can go stale
  • You need automatic background refetching
  • Multiple components use the same data and benefit from caching
// ✓ Zustand: auth state (app-wide, long-lived)
const { user, login } = useAuthStore();

// ✓ TanStack Query: post list (frequently refetched, cached)
const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: getPosts });

Shopping Cart with Async Persistence

interface CartState {
items: CartItem[];
isSyncing: boolean;
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
syncToServer: () => Promise<void>;
}

export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
isSyncing: false,

addItem: (product) => {
set((state) => {
const existing = state.items.find(i => i.productId === product.id);
if (existing) {
return {
items: state.items.map(i =>
i.productId === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
items: [...state.items, { productId: product.id, product, quantity: 1 }],
};
});

// Fire-and-forget sync
get().syncToServer();
},

removeItem: (productId) => {
set(state => ({ items: state.items.filter(i => i.productId !== productId) }));
get().syncToServer();
},

syncToServer: async () => {
const { items } = get();
set({ isSyncing: true });
try {
await fetch('/api/cart', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
} finally {
set({ isSyncing: false });
}
},
}),
{ name: 'cart-storage' }
)
);

Combining Stores

Stores can reference each other via getState():

// In notification store, reference auth store
import { useAuthStore } from './auth';

const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],

fetchNotifications: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;

const notifications = await getNotifications(userId);
set({ notifications });
},
}));

Slices Pattern (large stores)

Split a large store into slices:

// slices/ui.ts
const createUISlice = (set: SetState<AppState>) => ({
theme: 'light' as 'light' | 'dark',
sidebarOpen: false,
toggleTheme: () => set(s => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
});

// slices/auth.ts
const createAuthSlice = (set: SetState<AppState>) => ({
user: null as User | null,
login: async (email: string, password: string) => { /* ... */ },
});

// store.ts — combine slices
export const useAppStore = create<AppState>()((...args) => ({
...createUISlice(...args),
...createAuthSlice(...args),
}));