Modern State Management Solutions for React Applications
State management is one of the most critical aspects of building scalable React applications. Choosing the right solution can mean the difference between maintainable code and technical debt. Let's explore modern state management patterns and implement them in production scenarios.
The State Management Problem
As React applications grow, passing props through deeply nested components becomes unwieldy. State management solutions provide a way to:
- Share state globally - Avoid prop drilling
- Update state predictably - Clear patterns and data flow
- Debug easily - Time-travel debugging, devtools
- Scale efficiently - Handle complex application state
- Separate concerns - UI state from business logic
Context API
The built-in solution for many use cases.
Basic Setup
typescript
// contexts/theme-context.tsx
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
type Theme = "light" | "dark";
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
{children}
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}
Advanced Context with useReducer
typescript
// contexts/auth-context.tsx
"use client";
import { createContext, useContext, useReducer, ReactNode } from "react";
interface User {
id: string;
email: string;
name: string;
role: "user" | "admin";
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
type AuthAction =
| { type: "SIGN_IN"; payload: User }
| { type: "SIGN_OUT" }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "UPDATE_USER"; payload: Partial };
const initialState: AuthState = {
user: null,
isLoading: true,
error: null,
};
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "SIGN_IN":
return { ...state, user: action.payload, isLoading: false, error: null };
case "SIGN_OUT":
return { ...state, user: null, isLoading: false };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload };
case "UPDATE_USER":
return {
...state,
user: state.user ? { ...state.user, ...action.payload } : null,
};
default:
return state;
}
}
interface AuthContextType extends AuthState {
signIn: (email: string, password: string) => Promise;
signOut: () => Promise;
updateUser: (data: Partial) => Promise;
}
const AuthContext = createContext(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
const signIn = async (email: string, password: string) => {
dispatch({ type: "SET_LOADING", payload: true });
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Login failed");
const user = await res.json();
dispatch({ type: "SIGN_IN", payload: user });
} catch (error) {
dispatch({
type: "SET_ERROR",
payload: error instanceof Error ? error.message : "Unknown error",
});
}
};
const signOut = async () => {
await fetch("/api/auth/logout", { method: "POST" });
dispatch({ type: "SIGN_OUT" });
};
const updateUser = async (data: Partial) => {
try {
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Update failed");
dispatch({ type: "UPDATE_USER", payload: data });
} catch (error) {
dispatch({
type: "SET_ERROR",
payload: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
{children}
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
Zustand
Lightweight, modern state management library.
Basic Store
typescript
// store/user-store.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
interface User {
id: string;
name: string;
email: string;
preferences: {
theme: "light" | "dark";
notifications: boolean;
};
}
interface UserState {
user: User | null;
setUser: (user: User) => void;
updatePreferences: (preferences: Partial) => void;
logout: () => void;
}
export const useUserStore = create()(
devtools(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
updatePreferences: (preferences) =>
set((state) => {
if (!state.user) return state;
return {
user: {
...state.user,
preferences: { ...state.user.preferences, ...preferences },
},
};
}),
logout: () => set({ user: null }),
}),
{
name: "user-storage",
}
)
)
);
Advanced Store with Actions
typescript
// store/post-store.ts
import { create } from "zustand";
import { subscribeWithSelector, devtools } from "zustand/middleware";
interface Post {
id: string;
title: string;
content: string;
published: boolean;
createdAt: Date;
}
interface PostState {
posts: Post[];
selectedPost: Post | null;
isLoading: boolean;
error: string | null;
// Actions
fetchPosts: () => Promise;
selectPost: (id: string) => void;
createPost: (post: Omit) => Promise;
updatePost: (id: string, post: Partial) => Promise;
deletePost: (id: string) => Promise;
publishPost: (id: string) => Promise;
// Queries
getPostById: (id: string) => Post | undefined;
getPublishedPosts: () => Post[];
getDraftPosts: () => Post[];
}
export const usePostStore = create()(
subscribeWithSelector(
devtools((set, get) => ({
posts: [],
selectedPost: null,
isLoading: false,
error: null,
fetchPosts: async () => {
set({ isLoading: true, error: null });
try {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
const posts = await res.json();
set({ posts });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Unknown error",
});
} finally {
set({ isLoading: false });
}
},
selectPost: (id) => {
const post = get().getPostById(id);
set({ selectedPost: post || null });
},
createPost: async (postData) => {
try {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(postData),
});
if (!res.ok) throw new Error("Failed to create post");
const newPost = await res.json();
set((state) => ({ posts: [...state.posts, newPost] }));
} catch (error) {
set({
error: error instanceof Error ? error.message : "Unknown error",
});
}
},
updatePost: async (id, updates) => {
try {
const res = await fetch(/api/posts/${id}, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error("Failed to update post");
const updated = await res.json();
set((state) => ({
posts: state.posts.map((p) => (p.id === id ? updated : p)),
selectedPost:
state.selectedPost?.id === id ? updated : state.selectedPost,
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : "Unknown error",
});
}
},
deletePost: async (id) => {
try {
const res = await fetch(/api/posts/${id}, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete post");
set((state) => ({
posts: state.posts.filter((p) => p.id !== id),
selectedPost:
state.selectedPost?.id === id ? null : state.selectedPost,
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : "Unknown error",
});
}
},
publishPost: async (id) => {
await get().updatePost(id, { published: true });
},
getPostById: (id) => get().posts.find((p) => p.id === id),
getPublishedPosts: () => get().posts.filter((p) => p.published),
getDraftPosts: () => get().posts.filter((p) => !p.published),
}))
)
);
// Usage in components
export function PostList() {
const posts = usePostStore((state) => state.getPublishedPosts());
const { selectPost } = usePostStore();
return (
{posts.map((post) => (
selectPost(post.id)}>
{post.title}
))}
);
}
TanStack Query (React Query)
Best solution for server state management.
typescript
// hooks/use-posts.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface Post {
id: string;
title: string;
content: string;
published: boolean;
}
const POSTS_KEY = ["posts"] as const;
export function usePosts() {
return useQuery({
queryKey: POSTS_KEY,
queryFn: async () => {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function usePost(id: string) {
return useQuery({
queryKey: [...POSTS_KEY, id],
queryFn: async () => {
const res = await fetch(/api/posts/${id});
if (!res.ok) throw new Error("Failed to fetch post");
return res.json();
},
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (post: Omit) => {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(post),
});
if (!res.ok) throw new Error("Failed to create post");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: POSTS_KEY });
},
});
}
export function useUpdatePost(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: Partial) => {
const res = await fetch(/api/posts/${id}, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error("Failed to update post");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...POSTS_KEY, id] });
queryClient.invalidateQueries({ queryKey: POSTS_KEY });
},
});
}
// Usage in component
("use client");
import { usePosts, useCreatePost } from "@/hooks/use-posts";
export function PostsPage() {
const { data: posts, isLoading, error } = usePosts();
const { mutate: createPost, isPending } = useCreatePost();
if (isLoading) return
Loading...;
if (error) return
Error: {error.message};
return (
Posts
{posts?.map((post: Post) => (
{post.title}
))}
);
}
Redux Toolkit
For complex applications with predictable state flow.
typescript
// store/features/todos/todosSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
isLoading: boolean;
error: string | null;
}
const initialState: TodosState = {
items: [],
isLoading: false,
error: null,
};
export const fetchTodos = createAsyncThunk(
"todos/fetchTodos",
async (_, { rejectWithValue }) => {
try {
const res = await fetch("/api/todos");
if (!res.ok) throw new Error("Failed to fetch");
return await res.json();
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
export const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo: (state, action: PayloadAction) => {
state.items.push(action.payload);
},
toggleTodo: (state, action: PayloadAction) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action: PayloadAction) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.isLoading = true;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.isLoading = false;
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;
Comparison and When to Use
Context API
✅ Use when:
- Small to medium apps
- Simple theme/language state
- Authentication state
- Global UI state
❌ Avoid when:
- Frequent updates needed
- Complex state logic
- Large applications
- Need time-travel debugging
Zustand
✅ Use when:
- Client-side UI state
- Global preferences
- Modal/sidebar state
- Medium complexity stores
❌ Avoid when:
- Heavy server data fetching
- Complex async flows
TanStack Query
✅ Use when:
- Server state management
- API data fetching/caching
- Background synchronization
- Pagination/infinite queries
❌ Avoid when:
- Pure client-side state
Redux Toolkit
✅ Use when:
- Large enterprise apps
- Complex state dependencies
- Need predictable state management
- Team already familiar with Redux
❌ Avoid when:
- Simple applications
- Over-engineering small features
Best Practices
1. Separate concerns - Keep UI state and server state apart
2. Use TanStack Query for server state - Best-in-class solution
3. Use Zustand for UI state - Simple and lightweight
4. Avoid prop drilling - Use context or state management
5. Memoize selectors - Prevent unnecessary re-renders
6. Normalize state - Store flat structures, not nested
7. Keep reducers pure - No side effects in state logic
8. Test reducers - State mutations are testable
9. Use DevTools - Debug state changes easily
10. Monitor performance - Profile component renders
Conclusion
Modern state management is less about choosing the "best" solution and more about understanding your specific needs:
- Server state → TanStack Query
- UI state → Zustand
- Authentication → Context API
- Complex apps → Redux Toolkit
The key is using the right tool for the right job. Start simple, add complexity only when needed, and always measure performance! 🚀