Choosing the right state management approach depends on your needs. Here's how different solutions compare.
When to Use What#
Local state (useState):
- Component-specific data
- Form inputs
- UI state (open/closed)
Lifted state:
- Shared between few components
- Parent-child relationships
- Simple prop drilling acceptable
Context:
- Theme, locale, auth
- Infrequently changing data
- Avoiding deep prop drilling
External stores (Zustand, Redux):
- Complex state logic
- Frequent updates
- Shared across many components
- DevTools debugging needed
useState and useReducer#
1// Simple 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// Complex state with useReducer
13interface State {
14 items: Item[];
15 loading: boolean;
16 error: string | null;
17}
18
19type Action =
20 | { type: 'FETCH_START' }
21 | { type: 'FETCH_SUCCESS'; payload: Item[] }
22 | { type: 'FETCH_ERROR'; payload: string }
23 | { type: 'ADD_ITEM'; payload: Item };
24
25function reducer(state: State, action: Action): State {
26 switch (action.type) {
27 case 'FETCH_START':
28 return { ...state, loading: true, error: null };
29 case 'FETCH_SUCCESS':
30 return { ...state, loading: false, items: action.payload };
31 case 'FETCH_ERROR':
32 return { ...state, loading: false, error: action.payload };
33 case 'ADD_ITEM':
34 return { ...state, items: [...state.items, action.payload] };
35 default:
36 return state;
37 }
38}
39
40function ItemList() {
41 const [state, dispatch] = useReducer(reducer, {
42 items: [],
43 loading: false,
44 error: null,
45 });
46
47 // Use dispatch to update state
48}React Context#
1// Create context
2interface AuthContextType {
3 user: User | null;
4 login: (credentials: Credentials) => Promise<void>;
5 logout: () => void;
6}
7
8const AuthContext = createContext<AuthContextType | null>(null);
9
10// Provider
11function AuthProvider({ children }: { children: ReactNode }) {
12 const [user, setUser] = useState<User | null>(null);
13
14 const login = async (credentials: Credentials) => {
15 const user = await authApi.login(credentials);
16 setUser(user);
17 };
18
19 const logout = () => {
20 authApi.logout();
21 setUser(null);
22 };
23
24 return (
25 <AuthContext.Provider value={{ user, login, logout }}>
26 {children}
27 </AuthContext.Provider>
28 );
29}
30
31// Hook
32function useAuth() {
33 const context = useContext(AuthContext);
34 if (!context) {
35 throw new Error('useAuth must be used within AuthProvider');
36 }
37 return context;
38}
39
40// Usage
41function Profile() {
42 const { user, logout } = useAuth();
43
44 if (!user) return <LoginPrompt />;
45
46 return (
47 <div>
48 <p>Welcome, {user.name}</p>
49 <button onClick={logout}>Logout</button>
50 </div>
51 );
52}Zustand#
1import { create } from 'zustand';
2import { devtools, persist } from 'zustand/middleware';
3
4interface CartState {
5 items: CartItem[];
6 addItem: (item: CartItem) => void;
7 removeItem: (id: string) => void;
8 clearCart: () => void;
9 total: () => number;
10}
11
12const useCartStore = create<CartState>()(
13 devtools(
14 persist(
15 (set, get) => ({
16 items: [],
17
18 addItem: (item) =>
19 set((state) => ({
20 items: [...state.items, item],
21 })),
22
23 removeItem: (id) =>
24 set((state) => ({
25 items: state.items.filter((item) => item.id !== id),
26 })),
27
28 clearCart: () => set({ items: [] }),
29
30 total: () =>
31 get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
32 }),
33 { name: 'cart-storage' }
34 )
35 )
36);
37
38// Usage - no provider needed
39function Cart() {
40 const { items, removeItem, total } = useCartStore();
41
42 return (
43 <div>
44 {items.map((item) => (
45 <div key={item.id}>
46 {item.name} - ${item.price}
47 <button onClick={() => removeItem(item.id)}>Remove</button>
48 </div>
49 ))}
50 <p>Total: ${total()}</p>
51 </div>
52 );
53}
54
55// Select specific slice (prevents unnecessary re-renders)
56function CartCount() {
57 const count = useCartStore((state) => state.items.length);
58 return <span>Items: {count}</span>;
59}Redux Toolkit#
1import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';
2import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
3
4// Slice
5interface TodoState {
6 items: Todo[];
7 filter: 'all' | 'active' | 'completed';
8}
9
10const todoSlice = createSlice({
11 name: 'todos',
12 initialState: { items: [], filter: 'all' } as TodoState,
13 reducers: {
14 addTodo: (state, action: PayloadAction<string>) => {
15 state.items.push({
16 id: crypto.randomUUID(),
17 text: action.payload,
18 completed: false,
19 });
20 },
21 toggleTodo: (state, action: PayloadAction<string>) => {
22 const todo = state.items.find((t) => t.id === action.payload);
23 if (todo) {
24 todo.completed = !todo.completed;
25 }
26 },
27 setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
28 state.filter = action.payload;
29 },
30 },
31});
32
33// Store
34const store = configureStore({
35 reducer: {
36 todos: todoSlice.reducer,
37 },
38});
39
40type RootState = ReturnType<typeof store.getState>;
41type AppDispatch = typeof store.dispatch;
42
43// Typed hooks
44const useAppDispatch: () => AppDispatch = useDispatch;
45const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
46
47// Usage
48function TodoList() {
49 const dispatch = useAppDispatch();
50 const todos = useAppSelector((state) => state.todos.items);
51 const filter = useAppSelector((state) => state.todos.filter);
52
53 const filteredTodos = todos.filter((todo) => {
54 if (filter === 'active') return !todo.completed;
55 if (filter === 'completed') return todo.completed;
56 return true;
57 });
58
59 return (
60 <ul>
61 {filteredTodos.map((todo) => (
62 <li
63 key={todo.id}
64 onClick={() => dispatch(todoSlice.actions.toggleTodo(todo.id))}
65 >
66 {todo.text}
67 </li>
68 ))}
69 </ul>
70 );
71}Jotai (Atomic State)#
1import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
2
3// Primitive atoms
4const countAtom = atom(0);
5const textAtom = atom('');
6
7// Derived atom
8const doubledCountAtom = atom((get) => get(countAtom) * 2);
9
10// Writable derived atom
11const countWithValidationAtom = atom(
12 (get) => get(countAtom),
13 (get, set, newValue: number) => {
14 if (newValue >= 0) {
15 set(countAtom, newValue);
16 }
17 }
18);
19
20// Async atom
21const userAtom = atom(async () => {
22 const response = await fetch('/api/user');
23 return response.json();
24});
25
26// Usage
27function Counter() {
28 const [count, setCount] = useAtom(countAtom);
29 const doubled = useAtomValue(doubledCountAtom);
30
31 return (
32 <div>
33 <p>Count: {count}</p>
34 <p>Doubled: {doubled}</p>
35 <button onClick={() => setCount((c) => c + 1)}>Increment</button>
36 </div>
37 );
38}Comparison Table#
| Feature | useState | Context | Zustand | Redux | Jotai |
|------------------|----------|---------|---------|--------|--------|
| Boilerplate | Low | Medium | Low | Medium | Low |
| Learning curve | Easy | Easy | Easy | Medium | Easy |
| DevTools | React | React | Yes | Yes | Yes |
| Persistence | Manual | Manual | Built-in| Manual | Manual |
| Bundle size | 0 | 0 | ~3KB | ~11KB | ~4KB |
| Re-render control| Manual | Poor | Good | Good | Good |
| Async support | Manual | Manual | Manual | RTK Q | Built-in|
| SSR support | Yes | Yes | Yes | Yes | Yes |
Best Practices#
General:
✓ Start simple, add complexity as needed
✓ Colocate state with usage
✓ Avoid premature optimization
✓ Use selectors for derived data
Context:
✓ Split contexts by domain
✓ Memoize provider values
✓ Avoid frequent updates
External stores:
✓ Normalize complex data
✓ Use selectors to prevent re-renders
✓ Keep actions small and focused
✓ Leverage middleware for side effects
Conclusion#
Start with useState for local state, use Context for app-wide infrequent data like themes. For complex or frequently updating state, Zustand offers simplicity while Redux provides structure. Choose based on team experience and app complexity.