State Management Patterns in React
Answer
React offers multiple state management approaches. Choosing the right one depends on the app's complexity, team preferences, and specific requirements.
State Management Spectrum
Local State (useState/useReducer)
// Simple local state
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// Complex local state with reducer
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<>
<button onClick={() => dispatch({ type: "ADD", text: "New" })}>
Add
</button>
{todos.map((todo) => (
<Todo
key={todo.id}
todo={todo}
onToggle={() => dispatch({ type: "TOGGLE", id: todo.id })}
/>
))}
</>
);
}
Context for Global State
// Combine useReducer with Context
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// Custom hooks for clean consumption
function useAppState() {
return useContext(StateContext);
}
function useAppDispatch() {
return useContext(DispatchContext);
}
Zustand (Minimal External Store)
import { create } from "zustand";
const useStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
// Async actions
fetchCount: async () => {
const count = await api.getCount();
set({ count });
},
}));
// Usage - only subscribes to what component uses
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
Redux Toolkit (Complex Apps)
import { createSlice, configureStore } from "@reduxjs/toolkit";
// Slice
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
// Store
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
});
// Usage with hooks
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(counterSlice.actions.increment())}>
{count}
</button>
);
}
Server State (React Query)
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function UserList() {
// Fetching
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
// Mutations
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return (
<>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
<button onClick={() => createMutation.mutate({ name: "New User" })}>
Add User
</button>
</>
);
}
When to Use What
| Scenario | Solution |
|---|---|
| Form state | Local useState |
| UI state (modals, tabs) | Local useState |
| Theme, auth | Context |
| Complex UI state | useReducer + Context |
| Large app, time-travel debug | Redux |
| Minimal global state | Zustand, Jotai |
| Server data (CRUD) | React Query, SWR |
| Real-time data | React Query + WebSocket |
Pattern: Colocating State
// ✅ Keep state close to where it's used
function SearchPage() {
return (
<div>
<SearchBar /> {/* Has its own search state */}
<Results /> {/* Has its own pagination state */}
</div>
);
}
// ❌ Don't put everything in global store
// - Authentication status ✅ global
// - Modal open/close ❌ local
// - Form input values ❌ local
// - API response data ✅ React Query
Pattern: Derived State
function ShoppingCart({ items }) {
// ❌ Don't store derived values
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
// ✅ Calculate during render
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price, 0),
[items]
);
}
Combining Approaches
// Modern React app typically uses:
// 1. Local state for UI
// 2. React Query for server state
// 3. Context or Zustand for shared client state
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
{" "}
{/* Context for auth */}
<ThemeProvider>
{" "}
{/* Context for theme */}
<Router>
<Routes />
</Router>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
Key Points
- Start with local state, lift when needed
- Context works for infrequent updates
- React Query/SWR for server state
- Zustand for simple global client state
- Redux for complex apps needing DevTools
- Don't store derived state
- Colocate state with components that use it