React State Management: When to Use Context, Zustand, or Redux
State management is the topic React developers argue about most and agree on least. There is no single right answer, but there is a right answer for your specific situation. This article lays out the decision clearly.
Start Here: The Right Default is Local State
Before reaching for any library, ask yourself: does this state need to be shared?
If the answer is no, useState or useReducer inside the component is the correct choice.
// This state belongs here — no one else needs it
function SearchBar() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search articles..."
/>
);
}
For complex local state with multiple related values and transitions, useReducer is cleaner than multiple useState calls:
type State = { count: number; step: number; loading: boolean };
type Action =
| { type: "increment" }
| { type: "set_step"; payload: number }
| { type: "set_loading"; payload: boolean };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment": return { ...state, count: state.count + state.step };
case "set_step": return { ...state, step: action.payload };
case "set_loading": return { ...state, loading: action.payload };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1, loading: false });
// ...
}
When to Lift State Up
When two siblings need the same state, lift it to their closest common parent. This is React's intended first step before any external library.
function App() {
const [selectedTag, setSelectedTag] = useState<string | null>(null);
return (
<>
<TagFilter selected={selectedTag} onSelect={setSelectedTag} />
<ArticleList filterTag={selectedTag} />
</>
);
}
React Context — For Stable, Global Values
Context is designed for state that is global and changes infrequently: theme, locale, authenticated user, feature flags.
interface AuthContextValue {
user: User | null;
signIn: (credentials: Credentials) => Promise<void>;
signOut: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
async function signIn(credentials: Credentials) {
const user = await api.auth.signIn(credentials);
setUser(user);
}
function signOut() {
api.auth.signOut();
setUser(null);
}
return (
<AuthContext.Provider value={{ user, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
Context is NOT a state management library. Its critical limitation: every consumer re-renders whenever the context value changes. For high-frequency updates (mouse position, form input, pagination) this causes serious performance problems.
Split contexts by update frequency. A ThemeContext that changes once should not be in the same context as CartContext that changes on every item add.
Zustand — The Sweet Spot
Zustand is a minimal, fast, and straightforward global state library. It avoids the boilerplate of Redux while solving Context's re-render problem with built-in selector support.
npm install zustand
import { create } from "zustand";
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
export 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) })),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
Usage in any component (no Provider required):
function CartBadge() {
// Only re-renders when items.length changes — not on every state mutation
const count = useCartStore(state => state.items.length);
return <span className="badge">{count}</span>;
}
function CartTotal() {
const total = useCartStore(state => state.total());
return <p>Total: ${total.toFixed(2)}</p>;
}
Zustand's selector pattern means components only re-render when the specific slice they subscribe to changes. That is the core advantage over Context.
Zustand with Persistence
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useThemeStore = create<{ theme: "dark" | "light"; toggle: () => void }>()(
persist(
(set, get) => ({
theme: "dark",
toggle: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
}),
{ name: "theme-storage" } // key in localStorage
)
);
Redux Toolkit — For Large Teams and Complex State
Redux Toolkit (RTK) is the official, modern way to write Redux. It eliminates the boilerplate of classic Redux and is the right choice when:
- Your team is large (10+ developers)
- You need powerful devtools (time-travel debugging, action history)
- You have complex state with many interdependent slices
- You are using RTK Query for server state
npm install @reduxjs/toolkit react-redux
// features/articles/articlesSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
export const fetchArticles = createAsyncThunk(
"articles/fetchAll",
async (topic?: string) => {
const url = topic ? `/api/articles?topic=${topic}` : "/api/articles";
const res = await fetch(url);
return res.json() as Promise<Article[]>;
}
);
const articlesSlice = createSlice({
name: "articles",
initialState: {
items: [] as Article[],
status: "idle" as "idle" | "loading" | "succeeded" | "failed",
error: null as string | null,
},
reducers: {
articleAdded(state, action: PayloadAction<Article>) {
state.items.push(action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArticles.pending, (state) => { state.status = "loading"; })
.addCase(fetchArticles.fulfilled, (state, action) => {
state.status = "succeeded";
state.items = action.payload;
})
.addCase(fetchArticles.rejected, (state, action) => {
state.status = "failed";
state.error = action.error.message ?? null;
});
},
});
export const { articleAdded } = articlesSlice.actions;
export default articlesSlice.reducer;
RTK Query — Server State Made Simple
RTK Query handles data fetching, caching, invalidation, and re-fetching. It replaces useEffect + useState fetch patterns entirely.
// services/articlesApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const articlesApi = createApi({
reducerPath: "articlesApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Article"],
endpoints: (builder) => ({
getArticles: builder.query<Article[], string | void>({
query: (topic) => topic ? `articles?topic=${topic}` : "articles",
providesTags: ["Article"],
}),
addArticle: builder.mutation<Article, Partial<Article>>({
query: (body) => ({ url: "articles", method: "POST", body }),
invalidatesTags: ["Article"], // auto-refetches getArticles after mutation
}),
}),
});
export const { useGetArticlesQuery, useAddArticleMutation } = articlesApi;
function ArticleList({ topic }: { topic?: string }) {
const { data: articles, isLoading, error } = useGetArticlesQuery(topic);
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage />;
return <ul>{articles?.map(a => <ArticleItem key={a.id} article={a} />)}</ul>;
}
The Decision Framework
| Scenario | Best Choice |
|---|---|
| State used by one component | useState / useReducer |
| State shared by 2–3 nearby components | Lift state up |
| Theme, locale, auth user | React Context |
| App-wide UI state (cart, modals, sidebar) | Zustand |
| Complex server data with caching | RTK Query or React Query |
| Large app, many devs, complex interactions | Redux Toolkit |
Conclusion
The most common mistake is reaching for Redux too early. Start with local state, lift it when you need to share it, add Context for stable globals, and reach for Zustand when Context's re-render problem appears. Only bring in Redux Toolkit when the team size or state complexity justifies its structure. The right state management is the simplest one that solves the actual problem.
References: