React state management is a mess of opinions. Every week theres a new library claiming to solve everything. Redux is dead. Long live Redux. Use Context. Dont use Context for state.
Heres my take after years of React apps: theres no best solution. Only the right tool for your situation.
The Decision Framework
1. useState: Start Here
For local component state, useState is perfect:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
Use when:
- State belongs to one component
- Simple values (strings, numbers, booleans)
- No complex state transitions
2. useReducer: Complex Local State
When state logic gets complex, useReducer is cleaner:
interface State {
items: Item[];
loading: boolean;
error: string | null;
}
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: Item[] }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'ADD_ITEM'; payload: Item }
| { type: 'REMOVE_ITEM'; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, items: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(i => i.id !== action.payload)
};
default:
return state;
}
}
function ItemList() {
const [state, dispatch] = useReducer(reducer, {
items: [],
loading: false,
error: null
});
// dispatch({ type: 'ADD_ITEM', payload: newItem })
}
Use when:
- Multiple related state values
- Complex state transitions
- State logic you want to test in isolation
3. Lifting State Up
Before reaching for global state, try lifting:
// Parent owns the state
function Parent() {
const [items, setItems] = useState<Item[]>([]);
return (
<>
<ItemList items={items} />
<ItemForm onAdd={(item) => setItems(prev => [...prev, item])} />
</>
);
}
// Children receive via props
function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
Use when:
- State is shared between siblings
- Prop drilling is 2-3 levels deep max
4. Context API: Infrequent Updates
Context is great for data that rarely changes:
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = useCallback(() => {
setTheme(t => t === 'light' ? 'dark' : 'light');
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
The Context performance problem:
When ANY context value changes, ALL consumers re-render. Split contexts to avoid this:
// ❌ Bad: One big context
const AppContext = createContext({ user, settings, cart, notifications });
// ✅ Good: Separate contexts
const UserContext = createContext(user);
const SettingsContext = createContext(settings);
const CartContext = createContext(cart);
Use Context for:
- Theme
- Locale/i18n
- Current user (auth state)
- Feature flags
Dont use Context for:
- Frequently updating data
- Large state objects
- State that changes on user interaction
5. Zustand: Simple External State
Zustand is my go-to for most apps. Simple API, good performance, no boilerplate:
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
total: () => number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
clearCart: () => set({ items: [] }),
total: () => {
return get().items.reduce((sum, item) => sum + item.price, 0);
}
}));
// Use it anywhere
function CartBadge() {
const itemCount = useCartStore((state) => state.items.length);
return <span>{itemCount}</span>;
}
function CartTotal() {
const total = useCartStore((state) => state.total());
return <span>${total}</span>;
}
Zustand selectors = performance:
// ❌ Bad: Re-renders on any store change
const { items, addItem } = useCartStore();
// ✅ Good: Only re-renders when items.length changes
const itemCount = useCartStore((state) => state.items.length);
Use Zustand when:
- Need global state
- Want minimal boilerplate
- Small to medium apps
- Dont need Redux DevTools or middleware ecosystem
6. Redux Toolkit: Large Apps
For large apps with complex state, Redux Toolkit is still solid:
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';
interface CartState {
items: CartItem[];
loading: boolean;
}
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], loading: false } as CartState,
reducers: {
addItem: (state, action: PayloadAction<CartItem>) => {
state.items.push(action.payload); // Immer handles immutability
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter(i => i.id !== action.payload);
},
},
});
const store = configureStore({
reducer: {
cart: cartSlice.reducer,
},
});
export const { addItem, removeItem } = cartSlice.actions;
export type RootState = ReturnType<typeof store.getState>;
// Component usage
function CartButton() {
const dispatch = useDispatch();
const itemCount = useSelector((state: RootState) => state.cart.items.length);
return (
<button onClick={() => dispatch(addItem(item))}>
Add to Cart ({itemCount})
</button>
);
}
Use Redux when:
- Large team/codebase
- Need powerful DevTools
- Complex async workflows (RTK Query)
- Need middleware (logging, persistence)
Server State: A Different Beast
For data from APIs, use dedicated tools:
// TanStack Query (React Query)
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={data} />;
}
Dont put server data in Redux/Zustand. TanStack Query handles:
- Caching
- Refetching
- Loading states
- Error handling
- Optimistic updates
Quick Reference
| Solution | Use For | Avoid For | |----------|---------|-----------| | useState | Simple local state | Complex transitions | | useReducer | Complex local state | Global state | | Context | Theme, auth, i18n | Frequent updates | | Zustand | Global state, simple API | Huge apps | | Redux Toolkit | Large apps, complex flows | Simple apps | | TanStack Query | Server state | Client-only state |
My Recommendations
- Start with useState - Most state is local
- Lift state before going global - Often enough
- Use TanStack Query for server state - Always
- Pick Zustand for client global state - Unless you need Redux ecosystem
- Use Context sparingly - Theme, auth, locale only
Further Reading
The best state management is the least state management. Keep state local until you cant. Then pick the simplest tool that solves your problem.
