The State Management Panic
Three months into React, a senior dev asked: “Why aren’t you using Redux?”
I panicked. Added Redux. My simple TODO app had actions, action creators, reducers, selectors, middleware, thunks.
500 lines of boilerplate for 3 pieces of state.
Most apps don’t need heavy state management.
The Ladder
Start simple. Climb only when you have to:
Level 5: Zustand/Jotai (Global, minimal)
Level 4: Redux Toolkit (Heavy global)
Level 3: Context + useReducer (Light global)
Level 2: Context API (Shared state)
Level 1: useState + props (Local state)
Start at Level 1. Move up only when you feel pain.
Level 1: useState + Props (80% of Cases)
When to use:
- State is local to component
- Only 2-3 components need the data
- Data doesn’t change frequently
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
setTodos([...todos, { id: Date.now(), text, done: false }]);
};
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} />
</div>
);
}
This is fine. Don’t over-engineer.
Level 2: Context API
When to use:
- Multiple components need same data
- Data doesn’t update frequently
- Tired of prop drilling
Perfect for: Theme, auth, configuration
Level 3: Context + useReducer
When to use:
- State logic is complex
- Multiple related state updates
- Transitions need to be predictable
Benefits: Complex logic centralized, explicit transitions, easier to test.
Level 4: Redux Toolkit
When to use:
- Large team needs predictable patterns
- Complex state with many interactions
- Need middleware
Don’t use if: App is small, you’re solo, no complex state.
Redux Toolkit fixes old Redux’s verbosity, but it’s still heavy.
Level 5: Zustand (My Current Favorite)
Why I love it:
- Tiny (1KB)
- No providers needed
- No boilerplate
- TypeScript-friendly
import create from 'zustand';
const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos,
{ id: Date.now().toString(), text, done: false }]
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
}))
}));
// Usage
const todos = useTodoStore(state => state.todos);
Performance tip: Subscribe only to what you need.
Server State vs Client State
Don’t confuse them.
- Client State: UI state, form inputs, theme
- Server State: User data, posts, database records
For server state, use React Query or SWR:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
return <div>{user?.name}</div>;
}
Don’t use Redux/Zustand for server data. React Query/SWR handles: caching, background refetching, loading states, error handling.
The Decision Tree
Do you need global state?
├─ No → useState + props
└─ Yes
└─ Is it server data?
├─ Yes → React Query / SWR
└─ No → Is it complex logic?
├─ No → Context API
└─ Yes → Zustand / Redux Toolkit
My Current Stack (2025)
Client state:
- Local:
useState - Shared (simple): Context API
- Shared (complex): Zustand
Server state:
- React Query
This handles 99% of apps.
Common Mistakes
1. Using Global State for Everything
// BAD
const useStore = create((set) => ({
isModalOpen: false // Only one component cares
}));
// GOOD
function MyComponent() {
const [isModalOpen, setIsModalOpen] = useState(false);
}
Rule: If only one component cares, keep it local.
2. Storing Derived State
// BAD
const useStore = create((set) => ({
items: [],
totalPrice: 0 // Can get out of sync!
}));
// GOOD
function Cart() {
const items = useStore(state => state.items);
const totalPrice = items.reduce((sum, item) =>
sum + item.price, 0
);
}
The best state management solution is the simplest one that solves your problem.
Most apps are fine with useState, Context, and React Query.
Start simple. Scale when you actually need to.