React State Management - Redux vs Zustand vs Context API
State management is one of the most crucial aspects of building scalable React applications. With various solutions available, choosing the right one can significantly impact your development experience and application performance.
Context API: React's Built-in Solution
Simple State Management
Perfect for small to medium applications with straightforward state needs:
// contexts/AuthContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: User }
| { type: 'LOGIN_ERROR'; payload: string }
| { type: 'LOGOUT' };
const initialState: AuthState = {
user: null,
isLoading: false,
error: null,
};
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'LOGIN_START':
return { ...state, isLoading: true, error: null };
case 'LOGIN_SUCCESS':
return { ...state, user: action.payload, isLoading: false };
case 'LOGIN_ERROR':
return { ...state, error: action.payload, isLoading: false };
case 'LOGOUT':
return { ...state, user: null };
default:
return state;
}
};
const AuthContext = createContext<{
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
} | null>(null);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
return (
<AuthContext.Provider value={{ state, dispatch }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Custom Hooks for Actions
// hooks/useAuthActions.ts
import { useAuth } from "../contexts/AuthContext";
export const useAuthActions = () => {
const { dispatch } = useAuth();
const login = async (email: string, password: string) => {
dispatch({ type: "LOGIN_START" });
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error("Login failed");
const user = await response.json();
dispatch({ type: "LOGIN_SUCCESS", payload: user });
} catch (error) {
dispatch({ type: "LOGIN_ERROR", payload: error.message });
}
};
const logout = () => {
dispatch({ type: "LOGOUT" });
};
return { login, logout };
};
Performance Optimization
// Separate contexts to prevent unnecessary re-renders
import React, { createContext, memo } from 'react';
// Split state and actions to optimize re-renders
const AuthStateContext = createContext<AuthState | null>(null);
const AuthActionsContext = createContext<{
login: (email: string, password: string) => Promise<void>;
logout: () => void;
} | null>(null);
// Memoized component to prevent re-renders
const UserProfile = memo(() => {
const authState = useContext(AuthStateContext);
if (!authState?.user) return null;
return (
<div>
<h2>{authState.user.name}</h2>
<p>{authState.user.email}</p>
</div>
);
});
Zustand: Lightweight and Flexible
Simple Store Creation
// stores/authStore.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
interface User {
id: string;
name: string;
email: string;
}
interface AuthStore {
user: User | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
(set, get) => ({
user: null,
isLoading: false,
error: null,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error("Login failed");
const user = await response.json();
set({ user, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
logout: () => {
set({ user: null });
},
clearError: () => {
set({ error: null });
},
}),
{
name: "auth-storage",
partialize: (state) => ({ user: state.user }), // Only persist user
}
),
{ name: "auth-store" }
)
);
Component Usage
// components/LoginForm.tsx
import React, { useState } from 'react';
import { useAuthStore } from '../stores/authStore';
const LoginForm: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Subscribe to specific state slices
const { login, isLoading, error } = useAuthStore((state) => ({
login: state.login,
isLoading: state.isLoading,
error: state.error,
}));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
};
Advanced Patterns with Zustand
// stores/appStore.ts - Multiple slices
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
// Slice interfaces
interface AuthSlice {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
interface TodoSlice {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
}
interface NotificationSlice {
notifications: Notification[];
addNotification: (message: string, type: "success" | "error") => void;
removeNotification: (id: string) => void;
}
// Create slices
const createAuthSlice = (set: any, get: any): AuthSlice => ({
user: null,
login: async (email, password) => {
// Implementation
},
logout: () => set({ user: null }),
});
const createTodoSlice = (set: any, get: any): TodoSlice => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now().toString(), text, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)),
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
});
// Combine slices
export const useAppStore = create<AuthSlice & TodoSlice & NotificationSlice>()(
subscribeWithSelector(
devtools((...a) => ({
...createAuthSlice(...a),
...createTodoSlice(...a),
...createNotificationSlice(...a),
}))
)
);
// Subscribe to state changes
useAppStore.subscribe(
(state) => state.user,
(user) => {
if (user) {
console.log("User logged in:", user.name);
}
}
);
Redux Toolkit: Powerful and Structured
Store Setup
// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { authApi } from "./api/authApi";
import authReducer from "./slices/authSlice";
import todoReducer from "./slices/todoSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
todos: todoReducer,
[authApi.reducerPath]: authApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
},
}).concat(authApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Slice Definition
// store/slices/authSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
// Async thunk for login
export const loginUser = createAsyncThunk(
"auth/loginUser",
async ({ email, password }: { email: string; password: string }, { rejectWithValue }) => {
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error("Login failed");
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const initialState: AuthState = {
user: null,
isLoading: false,
error: null,
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
logout: (state) => {
state.user = null;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action: PayloadAction<User>) => {
state.isLoading = false;
state.user = action.payload;
})
.addCase(loginUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const { logout, clearError } = authSlice.actions;
export default authSlice.reducer;
RTK Query for API Management
// store/api/authApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { RootState } from "../index";
interface User {
id: string;
name: string;
email: string;
}
interface LoginRequest {
email: string;
password: string;
}
interface LoginResponse {
user: User;
token: string;
}
export const authApi = createApi({
reducerPath: "authApi",
baseQuery: fetchBaseQuery({
baseUrl: "/api/auth/",
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ["User"],
endpoints: (builder) => ({
login: builder.mutation<LoginResponse, LoginRequest>({
query: (credentials) => ({
url: "login",
method: "POST",
body: credentials,
}),
invalidatesTags: ["User"],
}),
getProfile: builder.query<User, void>({
query: () => "profile",
providesTags: ["User"],
}),
updateProfile: builder.mutation<User, Partial<User>>({
query: (updates) => ({
url: "profile",
method: "PUT",
body: updates,
}),
invalidatesTags: ["User"],
}),
}),
});
export const { useLoginMutation, useGetProfileQuery, useUpdateProfileMutation } = authApi;
Typed Hooks
// store/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./index";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Component Usage
// components/LoginForm.tsx
import React, { useState } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { loginUser } from '../store/slices/authSlice';
import { useLoginMutation } from '../store/api/authApi';
const LoginForm: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const dispatch = useAppDispatch();
const { isLoading, error } = useAppSelector((state) => state.auth);
// Alternative: Using RTK Query
const [login, { isLoading: isLoginLoading, error: loginError }] = useLoginMutation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Using async thunk
dispatch(loginUser({ email, password }));
// Or using RTK Query
// try {
// await login({ email, password }).unwrap();
// } catch (error) {
// console.error('Login failed:', error);
// }
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
};
Performance Comparison
Bundle Size Impact
{
"libraries": {
"context-api": "0 KB (built-in)",
"zustand": "~13 KB",
"redux-toolkit": "~45 KB",
"redux-toolkit-query": "~55 KB"
},
"note": "Approximate gzipped sizes"
}
Re-render Optimization
// Context API - Manual optimization needed
const ExpensiveComponent = memo(() => {
const { expensiveData } = useContext(SomeContext);
return <div>{/* Expensive rendering */}</div>;
});
// Zustand - Automatic optimization with selectors
const ExpensiveComponent = () => {
const expensiveData = useStore((state) => state.expensiveData);
return <div>{/* Only re-renders when expensiveData changes */}</div>;
};
// Redux - Automatic optimization with useSelector
const ExpensiveComponent = () => {
const expensiveData = useAppSelector((state) => state.expensiveData);
return <div>{/* Only re-renders when expensiveData changes */}</div>;
};
When to Use Each Solution
Context API
- Best for: Small to medium apps, component-specific state, theme/auth state
- Pros: No extra dependencies, simple API, great for React patterns
- Cons: Can cause performance issues, lacks time-travel debugging
Zustand
- Best for: Medium apps, simple global state, gradual migration from Context
- Pros: Minimal boilerplate, TypeScript-first, great DX, small bundle size
- Cons: Less mature ecosystem, fewer development tools
Redux Toolkit
- Best for: Large apps, complex state logic, need for time-travel debugging
- Pros: Mature ecosystem, excellent DevTools, predictable updates, great for teams
- Cons: More boilerplate, steeper learning curve, larger bundle size
Migration Strategies
From Context to Zustand
// Before: Context API
const ThemeContext = createContext();
// After: Zustand
const useThemeStore = create((set) => ({
theme: "light",
toggleTheme: () =>
set((state) => ({
theme: state.theme === "light" ? "dark" : "light",
})),
}));
From Zustand to Redux
// Before: Zustand store
const useAuthStore = create((set) => ({
user: null,
login: async (credentials) => {
/* ... */
},
}));
// After: Redux slice
const authSlice = createSlice({
name: "auth",
initialState: { user: null },
reducers: {
/* ... */
},
extraReducers: (builder) => {
builder.addCase(loginUser.fulfilled, (state, action) => {
state.user = action.payload;
});
},
});
Conclusion
Choosing the right state management solution depends on your project's complexity, team size, and specific requirements:
- Start simple with Context API for basic needs
- Scale up to Zustand for growing applications
- Go full Redux for complex, large-scale applications
Remember: you can always start with a simpler solution and migrate as your needs grow. The key is to choose the tool that best fits your current requirements while allowing for future growth.