Modern State Management
State management is one of the most critical aspects of building scalable React applications. This guide explores various state management solutions and helps you choose the right approach for your project.
Understanding State in React
State in React applications can be categorized into several types:
- Local Component State: Data that belongs to a single component
- Shared State: Data that multiple components need to access
- Global State: Application-wide data like user authentication
- Server State: Data that comes from external APIs
- URL State: Data stored in the URL (query params, route params)
Built-in React Solutions
useState and useReducer
Perfect for local and moderate shared state:
// Simple local state
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
// Complex state with useReducer
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'DELETE_TODO':
return state.filter(todo => todo.id !== action.id);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, []);
const addTodo = (text) => {
dispatch({ type: 'ADD_TODO', text });
};
return (
<div>
<TodoForm onSubmit={addTodo} />
<TodoList todos={todos} dispatch={dispatch} />
</div>
);
}
Context API
Great for avoiding prop drilling:
const ThemeContext = createContext();
const UserContext = createContext();
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<Header />
<MainContent />
<Footer />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// Custom hooks for cleaner usage
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
Third-Party Solutions
Zustand
Lightweight and simple state management:
import { create } from 'zustand';
const useStore = create((set, get) => ({
// State
count: 0,
user: null,
todos: [],
// Actions
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
setUser: (user) => set({ user }),
addTodo: (text) => set(state => ({
todos: [...state.todos, { id: Date.now(), text, done: false }]
})),
toggleTodo: (id) => set(state => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
})),
// Computed values
get completedTodos() {
return get().todos.filter(todo => todo.done);
}
}));
// Usage in components
function Counter() {
const { count, increment, decrement } = useStore();
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
Redux Toolkit
Modern Redux with less boilerplate:
import { createSlice, configureStore } from '@reduxjs/toolkit';
// Slice definition
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => {
state.value += 1; // Immer makes this immutable
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
}
}
});
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push({
id: Date.now(),
text: action.payload,
done: false
});
},
toggleTodo: (state, action) => {
const todo = state.find(todo => todo.id === action.payload);
if (todo) {
todo.done = !todo.done;
}
}
}
});
// Store configuration
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
todos: todosSlice.reducer
}
});
// Usage with React-Redux
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(counterSlice.actions.decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(counterSlice.actions.increment())}>+</button>
</div>
);
}
Jotai
Atomic approach to state management:
import { atom, useAtom } from 'jotai';
// Atomic state
const countAtom = atom(0);
const userAtom = atom(null);
const todosAtom = atom([]);
// Derived atoms
const completedTodosAtom = atom(
(get) => get(todosAtom).filter(todo => todo.done)
);
const todoStatsAtom = atom((get) => {
const todos = get(todosAtom);
const completed = get(completedTodosAtom);
return {
total: todos.length,
completed: completed.length,
remaining: todos.length - completed.length
};
});
// Usage in components
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
function TodoStats() {
const [stats] = useAtom(todoStatsAtom);
return (
<div>
Total: {stats.total}, Completed: {stats.completed}, Remaining: {stats.remaining}
</div>
);
}
Server State Management
React Query (TanStack Query)
Excellent for managing server state:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch data
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Hello, {user.name}!</div>;
}
// Mutations
function UpdateUserForm({ userId }) {
const queryClient = useQueryClient();
const updateUserMutation = useMutation({
mutationFn: (userData) => updateUser(userId, userData),
onSuccess: () => {
// Invalidate and refetch user data
queryClient.invalidateQueries(['user', userId]);
},
});
const handleSubmit = (userData) => {
updateUserMutation.mutate(userData);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button
type="submit"
disabled={updateUserMutation.isPending}
>
{updateUserMutation.isPending ? 'Updating...' : 'Update'}
</button>
</form>
);
}
SWR
Alternative to React Query:
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(res => res.json());
function UserList() {
const { data: users, error, mutate } = useSWR('/api/users', fetcher);
if (error) return <div>Failed to load</div>;
if (!users) return <div>Loading...</div>;
return (
<div>
<button onClick={() => mutate()}>Refresh</button>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Choosing the Right Solution
Decision Matrix
| Use Case | Solution | Why |
|---|---|---|
| Simple local state | useState | Built-in, simple |
| Complex local state | useReducer | Predictable updates |
| Avoiding prop drilling | Context API | Built-in, no extra deps |
| Medium app, simple global state | Zustand | Minimal boilerplate |
| Large app, complex state | Redux Toolkit | Mature, excellent devtools |
| Atomic state management | Jotai | Fine-grained reactivity |
| Server state | React Query/SWR | Caching, synchronization |
Combination Strategies
You can combine different solutions:
// Global app state with Zustand
const useAppStore = create((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
// Server state with React Query
function App() {
return (
<QueryClient client={queryClient}>
<ThemeProvider>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Router>
</ThemeProvider>
</QueryClient>
);
}
// Local state for component-specific data
function TodoForm() {
const [inputValue, setInputValue] = useState('');
const addTodoMutation = useMutation({
mutationFn: createTodo,
});
const handleSubmit = (e) => {
e.preventDefault();
addTodoMutation.mutate({ text: inputValue });
setInputValue('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button type="submit">Add Todo</button>
</form>
);
}
Best Practices
1. Keep State Close to Where It’s Used
Don’t lift state higher than necessary:
// Unnecessary global state
function App() {
const [modalOpen, setModalOpen] = useState(false);
return (
<div>
<Header onOpenModal={() => setModalOpen(true)} />
<Main />
{modalOpen && <Modal onClose={() => setModalOpen(false)} />}
</div>
);
}
// Keep modal state local
function Header() {
const [modalOpen, setModalOpen] = useState(false);
return (
<div>
<button onClick={() => setModalOpen(true)}>Open Modal</button>
{modalOpen && <Modal onClose={() => setModalOpen(false)} />}
</div>
);
}
2. Separate Concerns
Keep different types of state in appropriate places:
- UI state → Local component state or Context
- Global app state → Zustand, Redux, or Context
- Server state → React Query or SWR
- URL state → React Router or Next.js router
3. Normalize State Structure
Keep state flat and normalized:
// Nested, denormalized
const state = {
posts: [
{ id: 1, title: 'Post 1', author: { id: 1, name: 'John' } },
{ id: 2, title: 'Post 2', author: { id: 1, name: 'John' } },
]
};
// Flat, normalized
const state = {
posts: {
1: { id: 1, title: 'Post 1', authorId: 1 },
2: { id: 2, title: 'Post 2', authorId: 1 },
},
authors: {
1: { id: 1, name: 'John' },
}
};
Conclusion
Modern state management offers many excellent options. Start with React’s built-in solutions and add complexity only when needed. Consider the size of your application, team preferences, and specific requirements when choosing a state management solution.
Remember: the best state management solution is the one that makes your code easier to understand, maintain, and debug.
