When to Use
- Use when: prop drilling exceeds 2-3 levels
- Use when: async data fetching with loading/error/cache is tangled in useEffect
- Use when: multiple components read/write the same piece of state
- Do NOT use for: RSC data fetching — that's fetch() in server components (nextjs-patterns)
- Do NOT use for: form state — use react-hook-form (see frontend-patterns)
Decision Tree
State lives in...
└─ One component only → useState / useReducer
└─ URL (shareable, bookmarkable) → URL params / searchParams
└─ Server data (async, cached) → TanStack Query (React Query)
└─ Client UI state (cross-component, no async)
└─ Simple: 1-5 atoms → Jotai
└─ Complex: slices, middleware, devtools → Zustand
└─ Large team, strict patterns → Redux Toolkit
TanStack Query (Server State)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch with automatic caching, background refetch, loading/error states
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId], // cache key — refetches when userId changes
queryFn: () => api.users.get(userId),
staleTime: 60_000, // treat as fresh for 60s
gcTime: 300_000, // keep in cache for 5min after unmount
});
if (isLoading) return <Skeleton />;
if (error) return <Error />;
return <Profile user={data} />;
}
// Mutation with optimistic update
function FollowButton({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: (id: string) => api.users.follow(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['user', id] });
const prev = queryClient.getQueryData(['user', id]);
queryClient.setQueryData(['user', id], (old: User) => ({ ...old, following: true }));
return { prev }; // snapshot for rollback
},
onError: (_, id, ctx) => {
queryClient.setQueryData(['user', id], ctx?.prev); // rollback
},
onSettled: (_, __, id) => {
queryClient.invalidateQueries({ queryKey: ['user', id] });
},
});
return <button => mutate(userId)}>Follow</button>;
}
Zustand (Client UI State)
// store/useCartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem { id: string; qty: number; price: number; }
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
clear: () => void;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set(s => ({
items: s.items.find(i => i.id === item.id)
? s.items.map(i => i.id === item.id ? { ...i, qty: i.qty + item.qty } : i)
: [...s.items, item],
})),
removeItem: (id) => set(s => ({ items: s.items.filter(i => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.qty * i.price, 0),
clear: () => set({ items: [] }),
}),
{ name: 'cart-storage' } // persisted to localStorage
)
);
// In component
const { items, addItem, total } = useCartStore();
Jotai (Atomic State)
// Minimal — good for 1-10 isolated atoms
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
const themeAtom = atom<'light' | 'dark'>('light');
const sidebarAtom = atom(false);
// Derived atom (computed, no setter)
const isDarkAtom = atom(get => get(themeAtom) === 'dark');
// In components
function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom);
return <button => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
function Layout({ children }) {
const isDark = useAtomValue(isDarkAtom);
return <div className={isDark ? 'dark' : ''}>{children}</div>;
}
URL State (Shareable State)
// Use nuqs for type-safe URL search params
import { useQueryState, parseAsInteger } from 'nuqs';
function ProductList() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [sort, setSort] = useQueryState('sort', { defaultValue: 'name' });
const [query, setQuery] = useQueryState('q', { defaultValue: '' });
// URL: /products?page=2&sort=price&q=shirt
// State survives refresh, share link works out of the box
}
Anti-Fake-Pass Rules
Before claiming state management is implemented, you MUST show:
- Server state (API data) uses TanStack Query — not manual useEffect + useState
-
staleTimeconfigured — not just default (0 = refetch on every focus) - Mutations use
onMutatefor optimistic updates +onErrorfor rollback - Zustand stores are typed with explicit interfaces — not
any - No prop drilling > 2 levels — extract to store or context
- URL state used for filterable/sortable/paginated views — shareable links work
Reference: gates/anti-fake-pass-gate.md