React State Management in 2024: What to Use When
React state management has become simpler, not more complex. The ecosystem matured, and now we have clear answers for most situations.
The State Categories
1. Local Component State
Data used by one component only.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Use: useState, useReducer
2. Shared UI State
Data shared between a few components (theme, modal state).
// ThemeContext.js
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Usage
function Header() {
const { theme, setTheme } = useContext(ThemeContext);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>;
}
Use: useContext + useState
3. Global Client State
Complex state shared across many components.
// store.js using Zustand
import { create } from 'zustand';
const useStore = create((set) => ({
cart: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
removeFromCart: (id) => set((state) => ({
cart: state.cart.filter(item => item.id !== id)
})),
clearCart: () => set({ cart: [] })
}));
// Usage
function CartButton() {
const { cart, addToCart } = useStore();
return <span>Cart ({cart.length})</span>;
}
Use: Zustand (simple), Jotai (atomic), Redux Toolkit (complex)
4. Server State
Data from your backend that needs caching, sync, and updates.
// queries.js using TanStack Query
function useProducts(category) {
return useQuery({
queryKey: ['products', category],
queryFn: () => fetch(`/api/products?category=${category}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
}
// Usage
function ProductList({ category }) {
const { data, isLoading, error } = useProducts(category);
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return data.map(product => <ProductCard key={product.id} {...product} />);
}
Use: TanStack Query (React Query), SWR
Decision Framework
Is it from the server?
→ Yes: TanStack Query or SWR
→ No: Continue
Used by single component?
→ Yes: useState or useReducer
→ No: Continue
Used by 2-3 nearby components?
→ Yes: Lift state up + props
→ No: Continue
Is it simple global state?
→ Yes: Zustand or Jotai
→ No: Redux Toolkit (complex state machines)
Common Anti-Patterns
1. Redux for Everything
Don't use Redux for:
- Form state (use react-hook-form)
- Server data (use React Query)
- Simple shared state (use Zustand)
2. Context for Large State
Context re-renders all consumers on any change:
// Bad: Everything re-renders when any user field changes
<UserContext.Provider value={{ user, setUser, preferences, setPreferences }}>
Split contexts or use Zustand instead.
3. Over-fetching with useEffect
// Bad
useEffect(() => {
fetch('/api/data').then(setData);
}, []);
// Good: Use React Query
const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData
});
My 2024 Stack
- Local:
useState,useReducer - Forms:
react-hook-form - Server data: TanStack Query
- Global UI: Zustand
- Theme/Auth: Context (updates rarely)
Simple is maintainable. Choose the smallest tool that solves your problem.