React State Management: A Practical Comparison
2026-01-21 · 10 min read, 13 min code
I've refactored state management more times than I care to admit. Started with Redux because everyone was using it. Then moved to Context API because Redux felt like overkill. Then discovered Zustand and wondered why I'd been writing so much boilerplate. Then React Query changed how I think about server state entirely.
Here's what I've learned from building production applications with different state management approaches.
Table of Contents
- The Core Problem
- Understanding State Types
- Quick Decision Guide
- Redux: Still Relevant, But Less Popular
- Zustand: Redux Without the Boilerplate
- Jotai: Atomic State Management
- React Query: For Server State
- Context API: Built-in Solution
- URL State: For Shareable State
- Comparison Table
- Common Pitfalls
- My Recommended Stack
The Core Problem
State management in React comes down to one question: where do you put data that multiple components need?
You have a few options:
- Pass props down (prop drilling)
- Lift state up (works until it doesn't)
- Use a state management library (the real solution)
But which library? That's where it gets interesting.
Understanding State Types
Before comparing solutions, let's clarify something important: not all state is the same.
| Description | Characteristics | |
|---|---|---|
| Server state | Data from APIs, databases, or external sources | Can become stale, needs caching, requires synchronization |
| Client state | UI state, form inputs, theme preferences | Lives entirely in the browser |
| Derived state | Computed from other state | Like a filtered list or a total count |
Different state types need different solutions. That's why one-size-fits-all approaches often feel clunky.
Quick Decision Guide
| If your state is... | Then use... | Examples |
|---|---|---|
| Data from an API | React Query (or SWR) | User profiles, product listings, API responses |
| Shareable/bookmarkable | URL State | Search queries, filters, pagination, view modes |
| Component-specific | useState | Form inputs, toggle states, local UI state |
| Simple & rarely changes | Context API | Theme, simple auth, app configuration |
| Needs atomic/composable state | Jotai (or Recoil) | Complex derived state, fine-grained reactivity |
| Already using Redux | Redux Toolkit | Legacy codebases, existing Redux apps |
| Global client state (default) | Zustand | Shopping carts, modals, user preferences |
Redux: Still Relevant, But Less Popular
Let's be honest: Redux isn't as popular as it used to be. In 2024-2026, most new projects choose Zustand or React Query instead. But Redux still has its place, especially with Redux Toolkit making it less painful.
Why Redux Lost Popularity
The boilerplate problem: Classic Redux requires a lot of code for simple operations. Action types, action creators, reducers—it adds up fast.
Better alternatives emerged: Zustand offers the same benefits with way less code. React Query handles server state better than Redux ever did.
Over-engineering: Many teams used Redux when they didn't need it, leading to unnecessary complexity.
Redux Toolkit: The Modern Way
If you're going to use Redux, use Redux Toolkit (RTK). It reduces boilerplate significantly with createSlice and automatic Immer integration. Redux Toolkit is much better than classic Redux, but it's still more verbose than Zustand for simple cases.
When Redux Still Makes Sense
| Good for | Avoid when |
|---|---|
| Existing Redux codebases | New projects (use Zustand + React Query) |
| Large teams with strict patterns | Simple applications |
| Complex state machines | Small teams |
| Time-travel debugging needs | Server state (use React Query) |
| Legacy codebases | When you want less boilerplate |
Zustand: Redux Without the Boilerplate
Zustand is what Redux would be if it were designed today. Same concepts, way less code. No providers, no action creators, no reducers. Just a store and hooks.
Production Example: Shopping Cart
Here's a complete shopping cart implementation with Zustand, including persistence, error handling, and loading states:
Show code (94 lines, typescript)
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { devtools } from 'zustand/middleware'
interface CartItem {
id: string
name: string
price: number
quantity: number
image?: string
}
interface CartStore {
items: CartItem[]
error: string | null
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
totalItems: number
totalPrice: number
getItemQuantity: (id: string) => number
}
const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
error: null,
addItem: (item) => {
const existingItem = get().items.find((i) => i.id === item.id)
if (existingItem) {
set((state) => ({
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
}))
} else {
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
}))
}
},
removeItem: (id) => {
set((state) => ({
items: state.items.filter((item) => item.id !== id),
}))
},
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
get().removeItem(id)
return
}
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
}))
},
clearCart: () => set({ items: [], error: null }),
// Computed values
get totalItems() {
return get().items.reduce((sum, item) => sum + item.quantity, 0)
},
get totalPrice() {
return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
getItemQuantity: (id) => get().items.find((i) => i.id === id)?.quantity ?? 0,
}),
{
name: 'cart-storage', // localStorage key
storage: createJSONStorage(() => localStorage),
// Only persist items, not loading/error states
partialize: (state) => ({ items: state.items }),
}
),
{ name: 'CartStore' } // DevTools name
)
)
// Usage in components
function CartButton() {
// Only re-renders when totalItems changes
const totalItems = useCartStore((state) => state.totalItems)
const addItem = useCartStore((state) => state.addItem)
return (
<div>
<button>Cart ({totalItems})</button>
<button onClick={() => addItem({ id: '1', name: 'Product', price: 10 })}>
Add Item
</button>
</div>
)
}
Key features: persistence (localStorage), selective subscriptions (components only re-render when their selected state changes), error handling, TypeScript support, DevTools integration, and computed values (totalItems, totalPrice).
When to Use Zustand
| Good for | Avoid when |
|---|---|
| Most client state needs | Server state (use React Query) |
| When Redux feels like overkill | Need Redux's ecosystem/tooling |
| Global UI state (theme, modals, etc.) | Complex state machines (consider XState) |
| Form state that needs to be shared | |
| Shopping carts, user preferences |
Why Zustand Over Custom Solutions?
Zustand provides selective subscriptions, no provider hell (works without wrapping your app), built-in DevTools, middleware ecosystem (persist, immer, etc.), and excellent TypeScript inference.
For extremely simple state, Context API is fine. But for most cases, Zustand's 1KB bundle size and zero boilerplate make it better than rolling your own.
Jotai: Atomic State Management
Jotai takes a different approach: atomic state. Instead of one big store, you have many small atoms that compose together. Atoms are composable—you can derive new atoms from existing ones, creating a graph of state dependencies.
Note: Recoil is another atomic state management library by Meta. Jotai is generally preferred for its smaller bundle size and better TypeScript support, but Recoil is worth considering if you're already using other Meta libraries.
Production Example: Complex Derived State
Here's a real-world example managing product filters with complex derived state:
Show code (74 lines, typescript)
import { atom, useAtom, useAtomValue } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// Base atoms - individual pieces of state
const searchQueryAtom = atom('')
const selectedCategoryAtom = atom<string | null>(null)
const priceRangeAtom = atom<[number, number]>([0, 1000])
const sortByAtom = atomWithStorage<'price' | 'name' | 'rating'>('sortBy', 'price')
const itemsPerPageAtom = atom(12)
const currentPageAtom = atom(1)
// Derived atom: filter function
const filterFunctionAtom = atom((get) => {
const query = get(searchQueryAtom).toLowerCase()
const category = get(selectedCategoryAtom)
const [minPrice, maxPrice] = get(priceRangeAtom)
return (product: Product) => {
// Search query filter
if (query && !product.name.toLowerCase().includes(query)) {
return false
}
// Category filter
if (category && product.category !== category) {
return false
}
// Price range filter
if (product.price < minPrice || product.price > maxPrice) {
return false
}
return true
}
})
// Derived atom: filtered products
const filteredProductsAtom = atom((get) => {
const filterFn = get(filterFunctionAtom)
const allProducts = get(allProductsAtom) // Assume this comes from React Query
return allProducts.filter(filterFn)
})
// Derived atom: sorted products
const sortedProductsAtom = atom((get) => {
const products = get(filteredProductsAtom)
const sortBy = get(sortByAtom)
return [...products].sort((a, b) => {
switch (sortBy) {
case 'price':
return a.price - b.price
case 'name':
return a.name.localeCompare(b.name)
case 'rating':
return b.rating - a.rating
default:
return 0
}
})
})
// Components only re-render when their atoms change
function ProductList() {
const products = useAtomValue(sortedProductsAtom)
const [searchQuery, setSearchQuery] = useAtom(searchQueryAtom)
const [category, setCategory] = useAtom(selectedCategoryAtom)
return (
<div>
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
<select value={category || ''} onChange={(e) => setCategory(e.target.value || null)}>
<option value="">All Categories</option>
</select>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
Key benefits: fine-grained reactivity (components only re-render when their atoms change), composable derived state, automatic updates, TypeScript inference, and persistence support.
When to Use Jotai
| Good for | Avoid when |
|---|---|
| Complex derived state | Simple state (use Zustand or local state) |
| Fine-grained control | Single store model preference |
| Applications with many independent state pieces | Team unfamiliar with atomic patterns |
| Async state management |
React Query: For Server State
React Query (TanStack Query) isn't just a state management library—it's a server state solution. It handles caching, refetching, background updates, and error states automatically.
Alternative: SWR by Vercel is another excellent server state solution. React Query (TanStack Query) is more feature-rich, but SWR is simpler and has a smaller API surface. Both are excellent choices.
Production Example: Authentication Flow
Here's a complete authentication implementation with React Query, including token management, automatic refresh, and protected routes:
Show code (92 lines, typescript)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// API functions
async function loginUser(credentials: { email: string; password: string }) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!res.ok) throw new Error('Invalid credentials')
return res.json()
}
async function fetchCurrentUser(token: string) {
const res = await fetch('/api/user/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch user')
return res.json()
}
// Auth store with Zustand (for client-side auth state)
interface AuthStore {
token: string | null
setToken: (token: string | null) => void
logout: () => void
}
const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
token: null,
setToken: (token) => set({ token }),
logout: () => set({ token: null }),
}),
{ name: 'auth-storage' }
)
)
// React Query hooks
function useLogin() {
const queryClient = useQueryClient()
const setToken = useAuthStore((state) => state.setToken)
return useMutation({
mutationFn: loginUser,
onSuccess: (data) => {
setToken(data.token)
queryClient.invalidateQueries({ queryKey: ['currentUser'] })
},
})
}
function useCurrentUser() {
const token = useAuthStore((state) => state.token)
return useQuery({
queryKey: ['currentUser', token],
queryFn: () => fetchCurrentUser(token!),
enabled: !!token,
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) => {
if (error instanceof Error && error.message.includes('401')) return false
return failureCount < 3
},
})
}
function LoginForm() {
const login = useLogin()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
return (
<form onSubmit={(e) => { e.preventDefault(); login.mutate({ email, password }) }}>
{login.isError && <div className="error">Login failed</div>}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<button type="submit" disabled={login.isPending}>
{login.isPending ? 'Logging in...' : 'Login'}
</button>
</form>
)
}
function UserProfile() {
const { data: user, isLoading, error } = useCurrentUser()
const logout = useAuthStore((state) => state.logout)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
if (!user) return <div>Not authenticated</div>
return (
<div>
<h1>Welcome, {user.name}</h1>
<button onClick={logout}>Logout</button>
</div>
)
}
Key features: automatic caching (5 minutes), token management with Zustand persistence, error handling with retry logic, optimistic updates, request deduplication, automatic background refetching, and full TypeScript support.
When to Use React Query
| Good for | Avoid when |
|---|---|
| Any data from an API | Client-only state (use Zustand or local state) |
| Data that needs to stay fresh | Simple static data |
| Complex data fetching logic | Data that doesn't come from an API |
| Pagination, infinite scroll | |
| Real-time data synchronization |
Context API: Built-in Solution
React's Context API is built-in and works fine for simple cases. No external dependencies.
Production Example: Theme Management
Here's a simple theme implementation with persistence:
Show code (49 lines, typescript)
import { createContext, useContext, useEffect, useState } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'
setTheme: (theme: 'light' | 'dark') => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem('theme')
return (stored as 'light' | 'dark') || 'light'
})
useEffect(() => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// Custom hook with error handling
function useTheme() {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
// Usage in components
function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme}>
{theme === 'dark' ? '🌙' : '☀️'}
</button>
)
}
Key features: localStorage persistence, SSR-safe, TypeScript support, and simple API.
When to Use Context API
| Good for | Avoid when |
|---|---|
| Simple theme/configuration | Frequently updating state |
| Authentication state (if simple) | Complex state logic |
| Small apps with minimal state | Need for middleware/persistence |
| State that doesn't change often | Performance-critical applications |
| Avoiding external dependencies |
URL State: For Shareable State
URL state is often overlooked, but it's perfect for certain types of state. If state should be shareable, bookmarkable, or preserved on page refresh, it belongs in the URL.
Production Example: Search with Filters and React Query
Here's a complete search implementation combining URL state with React Query, including debouncing, pagination, error handling, and loading states:
Show code (104 lines, typescript)
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState, useEffect } from 'react'
import { useDebouncedCallback } from 'use-debounce'
interface SearchFilters {
query: string
category: string | null
minPrice: number | null
maxPrice: number | null
sort: 'relevance' | 'price-asc' | 'price-desc' | 'date'
page: number
}
interface SearchResult {
id: string
title: string
price: number
category: string
image: string
}
interface SearchResponse {
results: SearchResult[]
total: number
page: number
totalPages: number
}
async function searchProducts(filters: SearchFilters): Promise<SearchResponse> {
const params = new URLSearchParams()
if (filters.query) params.set('q', filters.query)
if (filters.category) params.set('category', filters.category)
if (filters.minPrice !== null) params.set('minPrice', filters.minPrice.toString())
if (filters.maxPrice !== null) params.set('maxPrice', filters.maxPrice.toString())
params.set('sort', filters.sort)
params.set('page', filters.page.toString())
const res = await fetch(`/api/products/search?${params.toString()}`)
if (!res.ok) throw new Error('Search failed')
return res.json()
}
// Custom hook: URL state management
function useSearchFilters() {
const router = useRouter()
const searchParams = useSearchParams()
const filters = useMemo<SearchFilters>(() => ({
query: searchParams.get('q') || '',
category: searchParams.get('category'),
minPrice: searchParams.get('minPrice') ? parseInt(searchParams.get('minPrice')!) : null,
maxPrice: searchParams.get('maxPrice') ? parseInt(searchParams.get('maxPrice')!) : null,
sort: (searchParams.get('sort') as SearchFilters['sort']) || 'relevance',
page: parseInt(searchParams.get('page') || '1'),
}), [searchParams])
const updateFilters = useCallback((updates: Partial<SearchFilters>) => {
const newFilters = { ...filters, ...updates }
if (updates.page === undefined && Object.keys(updates).length > 0) {
newFilters.page = 1
}
const params = new URLSearchParams()
if (newFilters.query) params.set('q', newFilters.query)
if (newFilters.category) params.set('category', newFilters.category)
if (newFilters.minPrice !== null) params.set('minPrice', newFilters.minPrice.toString())
if (newFilters.maxPrice !== null) params.set('maxPrice', newFilters.maxPrice.toString())
params.set('sort', newFilters.sort)
params.set('page', newFilters.page.toString())
router.push(`/search?${params.toString()}`, { scroll: false })
}, [filters, router])
return { filters, updateFilters }
}
function SearchPage() {
const { filters, updateFilters } = useSearchFilters()
const [inputValue, setInputValue] = useState(filters.query)
useEffect(() => setInputValue(filters.query), [filters.query])
const debouncedUpdateQuery = useDebouncedCallback(
(query: string) => updateFilters({ query }),
300
)
const { data, isLoading, isError, error, isFetching } = useQuery<SearchResponse>({
queryKey: ['search', filters],
queryFn: () => searchProducts(filters),
enabled: filters.query.length > 0 || filters.category !== null,
staleTime: 30 * 1000,
keepPreviousData: true,
})
return (
<div>
<input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
debouncedUpdateQuery(e.target.value)
}}
placeholder="Search products..."
/>
{isFetching && <span>Searching...</span>}
<div>
<CategoryFilter value={filters.category} onChange={(c) => updateFilters({ category: c })} />
<PriceRangeFilter
min={filters.minPrice}
max={filters.maxPrice}
onChange={(min, max) => updateFilters({ minPrice: min, maxPrice: max })}
/>
<SortSelect value={filters.sort} onChange={(s) => updateFilters({ sort: s })} />
</div>
{isLoading && <div>Loading...</div>}
{isError && <div>Error: {error instanceof Error ? error.message : 'Search failed'}</div>}
{data && (
<>
<p>Found {data.total} results</p>
{data.results.map((product) => (
<ProductCard key={product.id} product={product} />
))}
{data.totalPages > 1 && (
<div>
<button onClick={() => updateFilters({ page: data.page - 1 })} disabled={data.page === 1}>
Previous
</button>
<span>Page {data.page} of {data.totalPages}</span>
<button onClick={() => updateFilters({ page: data.page + 1 })} disabled={data.page === data.totalPages}>
Next
</button>
</div>
)}
</>
)}
</div>
)
}
Key features: URL state for shareability/bookmarking, debounced search, React Query integration, pagination, loading/error states, and TypeScript support.
Benefits: Shareable URLs (users can copy/bookmark state), browser navigation (back/forward works), SSR support, and SEO-friendly.
When to Use URL State
| Good for | Avoid when |
|---|---|
| Search queries and filters | Form inputs (unless you want them in URL) |
| Pagination state | UI-only state (modals, dropdowns, tooltips) |
| Tab/panel selection (if shareable) | Sensitive data (passwords, tokens) |
| View modes (list/grid, sort order) | Large/complex objects (URL length limits) |
| Modal/dialog state (if shareable) | Frequently changing state (history spam) |
| State that should be preserved on refresh | Temporary state (loading, errors) |
| State that should be shareable via URL |
Best Practices
- Keep URLs clean: Only include essential parameters (
/search?q=react&page=1&sort=date, not/search?q=react&page=1&sort=date&filter=all&view=list&theme=dark) - Use default values:
const query = searchParams.get('q') || '' - Debounce search inputs: Use
useDebouncedCallbackto avoid history spam - Sync with React Query: URL state stores parameters, React Query fetches data
Common patterns: Search with filters (/products?category=electronics&sort=price), pagination (/posts?page=3), and shareable modals (/products?modal=details&id=123).
Comparison Table
| Feature | Redux | Zustand | Jotai | React Query | Context API | URL State |
|---|---|---|---|---|---|---|
| Bundle Size | ~12KB | ~1KB | ~3KB | ~13KB | 0KB (built-in) | 0KB (built-in) |
| Boilerplate | High | Low | Low | Low | Low | Low |
| Learning Curve | Steep | Easy | Medium | Medium | Easy | Easy |
| DevTools | Excellent | Good | Good | Excellent | None | Browser DevTools |
| TypeScript | Good | Excellent | Excellent | Excellent | Good | Good |
| Server State | ❌ | ❌ | ⚠️ | ✅ | ❌ | ❌ |
| Client State | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (shareable) |
| Shareable | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Bookmarkable | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Performance | Good | Excellent | Excellent | Excellent | Poor (frequent updates) | Good |
| Middleware | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
Common Pitfalls
1. Using Redux for Everything
Don't put everything in Redux. Server state belongs in React Query. Simple UI state can be local.
2. Over-using Context API
Context API causes re-renders. If state updates frequently, use Zustand instead.
3. Mixing Server and Client State
Keep them separate. React Query for server, Zustand/Redux for client.
4. Not Using Selectors
In Redux, always use selectors. In Zustand, select only what you need:
// ❌ Bad - re-renders on any store change
const store = useCounterStore()
// ✅ Good - only re-renders when count changes
const count = useCounterStore((state) => state.count)
My Recommended Stack
For most applications, I use this combination:
-
React Query for all server state
- API calls, data fetching, caching
- Handles loading/error states automatically
-
URL State for shareable state
- Search queries, filters, pagination
- View modes, sort order
- Anything that should be bookmarkable or shareable
-
Zustand for global client state
- Theme, user preferences, UI state
- Shopping carts, modals, navigation
- State that doesn't need to be in URL
-
Local state (useState) for component-specific state
- Form inputs, toggle states
- Most common case
Here's how they work together:
Show code (22 lines, typescript)
function ProductListingPage() {
// URL State: shareable search/filters
const searchParams = useSearchParams()
const query = searchParams.get('q') || ''
// React Query: server data with caching
const { data: products } = useQuery({
queryKey: ['products', query],
queryFn: () => fetchProducts({ query }),
})
// Zustand: global cart state
const totalItems = useCartStore((state) => state.totalItems)
const addToCart = useCartStore((state) => state.addItem)
// Local state: component-specific UI
const [showModal, setShowModal] = useState(false)
return (
<div>
<SearchInput value={query} onChange={updateQuery} />
{products?.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={() => addToCart(product)}
/>
))}
<CartButton itemCount={totalItems} />
</div>
)
}
This covers 95% of use cases without Redux's complexity.
Further Reading
- Zustand Documentation - Recommended for most client state
- React Query (TanStack Query) Documentation - Essential for server state
- Jotai Documentation - For atomic/composable state patterns
- Redux Toolkit Documentation - If working with existing Redux codebases
- React Query Best Practices - Excellent guide by the maintainer
- Zustand vs Redux: A Comparison - Helpful migration reference
- SWR Documentation - Alternative to React Query
- Recoil Documentation - Alternative to Jotai