State Management Patterns
A comprehensive pattern library for managing complex state in React applications using Zustand, Jotai, and React Query. Covers the patterns that matter in production: optimistic updates with rollback, multi-tab sync, cache invalidation, real-time WebSocket state, and offline-first persistence. Each pattern includes standalone code, an architecture decision record, and TypeScript types that make impossible states unrepresentable.
Key Features
- Zustand Store Patterns — Slice architecture, middleware composition, devtools, and persistence
- Jotai Atomic State — Derived atoms, async atoms, atom families, and atom composition
- React Query Server State — Query key factories, optimistic mutations, infinite scroll, and prefetching
- Optimistic Updates — Instant UI feedback with server reconciliation and automatic rollback on failure
- Real-Time Sync — WebSocket event handlers that merge server pushes into client state without race conditions
- Offline-First Persistence — IndexedDB-backed state that syncs when connectivity resumes, with conflict resolution
- Multi-Tab Synchronization — BroadcastChannel API integration so state changes propagate across browser tabs
- Decision Framework — Flowcharts and comparison tables for choosing between Zustand, Jotai, React Query, and Context
Quick Start
- Install your preferred state management library (or combine them):
npm install zustand # client state
npm install jotai # atomic client state
npm install @tanstack/react-query # server state
Copy the
state/directory into your project'ssrc/lib/folder.Start with the recommended pattern — Zustand for client state + React Query for server state:
// lib/state/stores/cart-store.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartItem { id: string; name: string; price: number; quantity: number; }
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
totalPrice: () => number;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return { items: state.items.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i) };
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
Architecture / How It Works
state-management-patterns/
├── zustand/ # Store, slices, middleware, selectors, broadcast
├── jotai/ # Atoms, async atoms, families, persistence
├── react-query/ # Query keys, mutations, infinite scroll, websocket
├── combined/ # Zustand + Query hybrid, offline-first sync
├── decision-guides/ # When-to-use-what flowchart, Redux migration
└── examples/ # Full todo-app and dashboard examples
Usage Examples
React Query with Optimistic Updates
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, { method: 'PATCH', body: JSON.stringify(todo) }).then((r) => r.json()),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
);
return { previous };
},
onError: (_err, _todo, ctx) => queryClient.setQueryData(['todos'], ctx?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
}
Jotai Derived Atoms
import { atom } from 'jotai';
const filterAtom = atom<'all' | 'active' | 'completed'>('all');
const todosAtom = atom<Todo[]>([]);
const filteredTodosAtom = atom((get) => {
const filter = get(filterAtom);
const todos = get(todosAtom);
if (filter === 'active') return todos.filter((t) => !t.completed);
if (filter === 'completed') return todos.filter((t) => t.completed);
return todos;
});
Multi-Tab Sync with Zustand
const channel = new BroadcastChannel('app-state');
export const useAuthStore = create<AuthStore>((set) => {
channel.onmessage = (e) => { if (e.data.type === 'LOGOUT') set({ user: null, isAuthenticated: false }); };
return {
user: null, isAuthenticated: false,
logout: () => { set({ user: null, isAuthenticated: false }); channel.postMessage({ type: 'LOGOUT' }); },
};
});
Configuration
React Query Global Defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 5 * 60_000, gcTime: 30 * 60_000, retry: 2, refetchOnWindowFocus: true },
mutations: { retry: 1 },
},
});
Query Key Factory
export const queryKeys = {
todos: {
all: ['todos'] as const,
list: (filters: TodoFilters) => [...queryKeys.todos.all, 'list', filters] as const,
detail: (id: string) => [...queryKeys.todos.all, 'detail', id] as const,
},
};
Best Practices
- Separate client from server state — Zustand/Jotai for UI state, React Query for server data
-
Use selectors to prevent re-renders —
useStore((s) => s.items.length)only re-renders when count changes - Query keys are your cache topology — design hierarchically for targeted invalidation
-
Optimistic updates need rollback — always save previous state in
onMutatecontext - Persist only what's necessary — cart items yes, modal state no, sensitive data never
Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Component re-renders on every store update | Selecting the entire store object | Use a selector: useStore((s) => s.specificField) instead of useStore()
|
| React Query shows stale data after mutation | Missing invalidateQueries in onSettled
|
Add queryClient.invalidateQueries({ queryKey: [...] }) after mutation |
| Zustand persist causes hydration mismatch | Server renders default state, client loads persisted state | Use skipHydration option or onRehydrateStorage callback |
| Jotai atoms reset on component unmount | Atoms defined inside components | Define atoms at module level, outside components |
This is 1 of 11 resources in the Frontend Developer Pro toolkit. Get the complete [State Management Patterns] with all files, templates, and documentation for $29.
Or grab the entire Frontend Developer Pro bundle (11 products) for $129 — save 30%.
Top comments (0)