State Management
Choose the right state strategy — server state vs client state — without over-engineering.
Why
The most common React mistake is putting server data in useState/useReducer, which means no caching, no refetching, no optimistic updates, and stale data everywhere. Server state (data from the API) and client state (UI toggles, form inputs) have completely different lifecycles and should use different tools. Server state belongs in TanStack Query (caching, refetch, invalidation); client state belongs in useState or Zustand for cross-component sharing. Redux is almost never the right answer in 2026 — it was designed for a world before React Query and server components. Choosing correctly means less code, fewer bugs, and no 'why is this showing yesterday's data?' tickets.
When
When the app has ≥3 data-fetching components. Takes 1-2 h to set up TanStack Query. Skip for static content sites.
How
1. Install TanStack Query and wrap the app in QueryClientProvider
- const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } })
- Put it in the root layout, above the router
2. Use useQuery for all server state (GET requests)
- const { data, isLoading, error } = useQuery({ queryKey: ["users"], queryFn: fetchUsers })
- The key is the cache identity; same key = same data
3. Use useMutation for writes (POST/PUT/DELETE) with cache invalidation
- On success: queryClient.invalidateQueries({ queryKey: ["users"] })
- For optimistic updates: onMutate updates the cache before the server responds
4. Use useState for local UI state (modal open, selected tab)
- Use Zustand only if ≥3 sibling components need the same client state
- Never put server data in Zustand — it belongs in the query cache
5. Use React Context for dependency injection (theme, locale), not for state
- Context that re-renders on every state change causes perf issuesExample
// Server state — TanStack Query
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export function UserList() {
const { data: users, isLoading } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
if (isLoading) return <Spinner />;
return users.map(u => <UserRow key={u.id} user={u} onDelete={() => mutation.mutate(u.id)} />);
}
// Client state — Zustand (only for cross-component UI state)
import { create } from "zustand";
export const useUIStore = create<{ sidebarOpen: boolean; toggle: () => void }>((set) => ({
sidebarOpen: false, toggle: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));Common Problems
- ⚠Storing server data in useState and refetching manually, losing caching, dedup, and background refresh.
- ⚠Using Redux for everything because 'it's what we know', adding 200 lines of boilerplate for a toggle.
- ⚠Putting server state in Zustand, so two components show different versions of the same data.
- ⚠Context that holds frequently-changing state, causing every consumer to re-render on every update.
Best Practices
- ✓Default staleTime to 30-60s so repeated navigations don't refetch — users perceive instant page loads.
- ✓Use query key arrays with variables (['user', userId]) so each entity is cached independently.