A practical decision guide for React state management — when Context is enough, when Zustand is the right choice, and when Redux Toolkit is actually warranted.
State Management in 2025: Zustand, Redux, or Context?
State management in React has never been more contested — or more solved. The ecosystem has matured to the point where the default answer for most applications is clear, and the situations that genuinely require a heavy-weight solution are well-defined.
This guide cuts through the noise and gives you a practical framework for choosing the right state management approach for your specific situation, with concrete guidance on when each option is appropriate.
First: Separate Server State from Client State
The most important distinction in React state management is between server state and client state.
Server state is data that lives on the server and is fetched, cached, and synchronised on the client: user profiles, products, posts, API responses. This data has server truth — the server is the authority, and the client needs to stay in sync.
Client state is state that only exists in the client: whether a modal is open, which tab is selected, form input values, dark mode preference. This data doesn’t have a server equivalent.
Before choosing a state management library, know which type you’re dealing with. Server state is handled best by a data-fetching library (React Query, SWR, Apollo), not a general state store. Client state is handled by Context, Zustand, Redux, or similar.
Mixing these two concerns in a Redux store is one of the most common sources of unnecessary complexity in React applications.
React Context: When It’s Enough
Context is React’s built-in state sharing mechanism. Pass state down through the component tree without prop drilling — any component that needs it can consume it via useContext.
Context is the right choice when:
- The state is consumed by many components across the tree
- The state updates infrequently
- The data doesn’t require complex manipulation logic
Prototypical good use cases for Context:
- Current user session (user object, authentication status)
- Theme (dark/light mode, design tokens)
- Locale/language settings
- Feature flags
- Application configuration
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{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 Caveat
Context has a known performance characteristic: when the context value changes, all components consuming that context re-render — regardless of whether the specific value they use changed. For infrequently updated, low-value contexts (theme, locale), this is fine. For frequently updating state (mouse position, form values changing on every keystroke), Context can cause cascading re-renders that harm performance.
The fix is to either split contexts (separate contexts for values that update at different frequencies) or move to a library with built-in subscription granularity, like Zustand.
Zustand: The Sweet Spot for Most Applications
Zustand is a small, fast state management library with a minimal API. It solves the problems that make Context insufficient — performance, complex updates, middleware — without the ceremony of Redux.
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) => set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
total: () => get().items.reduce((sum, item) => sum + item.price, 0),
}));
// In a component — only re-renders when items changes
function CartCount() {
const count = useCartStore((state) => state.items.length);
return <span>{count} items</span>;
}
The selector pattern (useCartStore((state) => state.items.length)) is the key to Zustand’s performance. Components subscribe to specific slices of state and only re-render when those specific values change.
When to Choose Zustand
- You have state shared across multiple components that updates frequently
- You need computed values derived from state
- You want to co-locate state logic with the state itself (actions in the store)
- You want good TypeScript inference without ceremony
- You want DevTools support without the Redux overhead
Zustand handles 80% of the state management use cases that arise in real React applications. If you’re starting a new project and aren’t certain whether you’ll need Redux’s capabilities, start with Zustand.
Zustand with Persist Middleware
For state that should persist across page reloads (cart contents, user preferences):
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const usePreferencesStore = create(
persist(
(set) => ({
theme: 'light',
language: 'en-AU',
setTheme: (theme) => set({ theme }),
}),
{
name: 'user-preferences', // localStorage key
}
)
);
Redux Toolkit: When You Actually Need It
Redux has a reputation for excessive boilerplate that was earned by the pre-Redux Toolkit era. Redux Toolkit (RTK) is a different experience — it’s the modern way to use Redux and substantially reduces the setup overhead.
That said, Redux is still more complex than Zustand and the overhead is only justified in specific situations.
Redux Toolkit is the right choice when:
- Large teams need strict conventions. Redux’s action/reducer pattern enforces a consistent structure that teams can agree on and enforce with linting. Zustand’s flexibility becomes a liability when many developers are writing stores with different patterns.
- Complex state logic with time-travel debugging. Redux DevTools’ time-travel debugging is genuinely powerful for complex applications where understanding state history matters.
- You’re already in a Redux codebase. Migrating away from Redux is expensive. If the codebase uses Redux, use RTK to modernise it rather than introducing Zustand alongside.
- Your state mutations are complex and need testing isolation. Redux reducers are pure functions that are straightforward to unit test. For complex state logic, this is valuable.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle', error: null },
reducers: {
clearUser: (state) => { state.data = null; },
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => { state.status = 'loading'; })
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
Note: this async thunk pattern manages what is really server state (a fetched user). React Query would be cleaner here — but for teams committed to RTK, createAsyncThunk is the RTK way to handle it.
React Query / TanStack Query: For Server State
If you’re using Redux or Zustand to manage data fetched from an API, consider React Query (TanStack Query) instead or alongside your client state solution.
React Query handles:
- Fetching, caching, and background refetching of server data
- Loading and error states
- Cache invalidation and synchronisation
- Pagination and infinite scroll patterns
- Optimistic updates
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <Skeleton />;
return <div>{user.name}</div>;
}
The combination of React Query for server state and Zustand for client state handles the vast majority of production application state requirements cleanly and without the overhead of Redux.
The Decision Framework
| Situation | Recommended approach |
|---|---|
| Theme, locale, current user | React Context |
| Shared UI state (modals, tabs, cart) | Zustand |
| Frequently updating shared state | Zustand with selectors |
| Data fetched from an API | React Query / SWR |
| Large team, needs strict conventions | Redux Toolkit |
| Existing Redux codebase | Redux Toolkit (modernise with RTK) |
| Complex state with debugging needs | Redux Toolkit |
Conclusion
For most React applications in 2025, the right answer is: React Query for server state, Zustand for client state. Context for the few genuinely global, infrequently-updated values like theme and locale.
Redux is the right tool for the situations where its structure and conventions are genuinely needed — large teams, complex state logic, existing codebases. Adding Redux by default to a new project is almost always adding more complexity than the problem requires.
TL;DR
- Separate server state from client state — this is the most important decision
- React Context: theme, locale, current user — infrequently updated, widely consumed
- Zustand: shared client state that updates frequently; use selectors to prevent unnecessary re-renders
- Redux Toolkit: large teams needing strict conventions, complex state logic, existing Redux codebases
- React Query / TanStack Query: any data fetched from an API — don’t put server state in Redux or Zustand
- Default for new projects: React Query + Zustand — covers 90% of needs without Redux overhead