State management in React ranges from simple useState to complex global stores. The key is using the right tool for each situation—most apps need less than you think.
State Categories#
Local State:
- Form inputs
- UI toggles
- Component-specific data
→ useState, useReducer
Shared State:
- Theme, locale
- User session
- Feature flags
→ Context, Zustand
Server State:
- API responses
- Cached data
- Loading states
→ TanStack Query, SWR
URL State:
- Current page
- Filters, search
- Shareable state
→ Router params, searchParams
useState for Local State#
1// Simple local state
2function Counter() {
3 const [count, setCount] = useState(0);
4
5 return (
6 <button onClick={() => setCount((c) => c + 1)}>
7 Count: {count}
8 </button>
9 );
10}
11
12// Object state
13function Form() {
14 const [form, setForm] = useState({
15 name: '',
16 email: '',
17 });
18
19 const updateField = (field: string, value: string) => {
20 setForm((prev) => ({ ...prev, [field]: value }));
21 };
22
23 return (
24 <form>
25 <input
26 value={form.name}
27 onChange={(e) => updateField('name', e.target.value)}
28 />
29 <input
30 value={form.email}
31 onChange={(e) => updateField('email', e.target.value)}
32 />
33 </form>
34 );
35}useReducer for Complex State#
1interface State {
2 items: Item[];
3 loading: boolean;
4 error: string | null;
5 filter: string;
6}
7
8type Action =
9 | { type: 'FETCH_START' }
10 | { type: 'FETCH_SUCCESS'; payload: Item[] }
11 | { type: 'FETCH_ERROR'; payload: string }
12 | { type: 'SET_FILTER'; payload: string }
13 | { type: 'ADD_ITEM'; payload: Item }
14 | { type: 'REMOVE_ITEM'; payload: string };
15
16function reducer(state: State, action: Action): State {
17 switch (action.type) {
18 case 'FETCH_START':
19 return { ...state, loading: true, error: null };
20 case 'FETCH_SUCCESS':
21 return { ...state, loading: false, items: action.payload };
22 case 'FETCH_ERROR':
23 return { ...state, loading: false, error: action.payload };
24 case 'SET_FILTER':
25 return { ...state, filter: action.payload };
26 case 'ADD_ITEM':
27 return { ...state, items: [...state.items, action.payload] };
28 case 'REMOVE_ITEM':
29 return {
30 ...state,
31 items: state.items.filter((i) => i.id !== action.payload),
32 };
33 default:
34 return state;
35 }
36}
37
38function ItemList() {
39 const [state, dispatch] = useReducer(reducer, {
40 items: [],
41 loading: false,
42 error: null,
43 filter: '',
44 });
45
46 useEffect(() => {
47 dispatch({ type: 'FETCH_START' });
48 fetchItems()
49 .then((items) => dispatch({ type: 'FETCH_SUCCESS', payload: items }))
50 .catch((err) => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
51 }, []);
52
53 // ...
54}Context for Shared State#
1// Theme context
2interface ThemeContextType {
3 theme: 'light' | 'dark';
4 toggleTheme: () => void;
5}
6
7const ThemeContext = createContext<ThemeContextType | null>(null);
8
9function ThemeProvider({ children }: { children: React.ReactNode }) {
10 const [theme, setTheme] = useState<'light' | 'dark'>('light');
11
12 const toggleTheme = useCallback(() => {
13 setTheme((t) => (t === 'light' ? 'dark' : 'light'));
14 }, []);
15
16 return (
17 <ThemeContext.Provider value={{ theme, toggleTheme }}>
18 {children}
19 </ThemeContext.Provider>
20 );
21}
22
23function useTheme() {
24 const context = useContext(ThemeContext);
25 if (!context) {
26 throw new Error('useTheme must be used within ThemeProvider');
27 }
28 return context;
29}
30
31// Usage
32function Header() {
33 const { theme, toggleTheme } = useTheme();
34
35 return (
36 <header className={theme}>
37 <button onClick={toggleTheme}>Toggle Theme</button>
38 </header>
39 );
40}Zustand for Global State#
1import { create } from 'zustand';
2import { persist } from 'zustand/middleware';
3
4interface CartStore {
5 items: CartItem[];
6 addItem: (item: CartItem) => void;
7 removeItem: (id: string) => void;
8 updateQuantity: (id: string, quantity: number) => void;
9 clear: () => void;
10 total: () => number;
11}
12
13const useCartStore = create<CartStore>()(
14 persist(
15 (set, get) => ({
16 items: [],
17
18 addItem: (item) =>
19 set((state) => {
20 const existing = state.items.find((i) => i.id === item.id);
21 if (existing) {
22 return {
23 items: state.items.map((i) =>
24 i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
25 ),
26 };
27 }
28 return { items: [...state.items, { ...item, quantity: 1 }] };
29 }),
30
31 removeItem: (id) =>
32 set((state) => ({
33 items: state.items.filter((i) => i.id !== id),
34 })),
35
36 updateQuantity: (id, quantity) =>
37 set((state) => ({
38 items: state.items.map((i) =>
39 i.id === id ? { ...i, quantity } : i
40 ),
41 })),
42
43 clear: () => set({ items: [] }),
44
45 total: () => {
46 const { items } = get();
47 return items.reduce((sum, i) => sum + i.price * i.quantity, 0);
48 },
49 }),
50 {
51 name: 'cart-storage',
52 }
53 )
54);
55
56// Usage - no provider needed!
57function CartButton() {
58 const itemCount = useCartStore((s) => s.items.length);
59 return <button>Cart ({itemCount})</button>;
60}
61
62function CartTotal() {
63 const total = useCartStore((s) => s.total());
64 return <div>Total: ${total}</div>;
65}TanStack Query for Server State#
1import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
3function useUsers() {
4 return useQuery({
5 queryKey: ['users'],
6 queryFn: () => fetch('/api/users').then((r) => r.json()),
7 staleTime: 5 * 60 * 1000, // 5 minutes
8 });
9}
10
11function useCreateUser() {
12 const queryClient = useQueryClient();
13
14 return useMutation({
15 mutationFn: (newUser: CreateUserInput) =>
16 fetch('/api/users', {
17 method: 'POST',
18 body: JSON.stringify(newUser),
19 }).then((r) => r.json()),
20
21 onSuccess: () => {
22 queryClient.invalidateQueries({ queryKey: ['users'] });
23 },
24 });
25}
26
27function UserList() {
28 const { data: users, isLoading, error } = useUsers();
29 const createUser = useCreateUser();
30
31 if (isLoading) return <Loading />;
32 if (error) return <Error error={error} />;
33
34 return (
35 <div>
36 <button
37 onClick={() => createUser.mutate({ name: 'New User' })}
38 disabled={createUser.isPending}
39 >
40 Add User
41 </button>
42
43 {users.map((user) => (
44 <UserCard key={user.id} user={user} />
45 ))}
46 </div>
47 );
48}Decision Guide#
Use useState when:
- State is local to component
- State is simple (primitives, small objects)
- No need to share with siblings
Use useReducer when:
- Complex state logic
- Multiple related values
- Next state depends on previous
Use Context when:
- Theme, locale, auth
- Infrequently updated state
- Avoid prop drilling (2-3 levels)
Use Zustand when:
- Global app state
- Frequent updates
- Need persistence
- Don't want providers
Use TanStack Query when:
- API data fetching
- Caching needed
- Background refetching
- Optimistic updates
Skip Redux unless:
- Large team needs conventions
- Complex middleware requirements
- Time-travel debugging critical
Best Practices#
DO:
✓ Start with useState
✓ Colocate state with UI
✓ Split state by domain
✓ Use server state libraries for API data
✓ Derive state when possible
DON'T:
✗ Put everything in global state
✗ Use Context for frequently updating state
✗ Duplicate server state in client state
✗ Reach for Redux by default
Conclusion#
Most React apps need less state management than you think. Use useState for local state, TanStack Query for server state, and Zustand for the rare global client state.
Start simple and add complexity only when needed.