← Back to Posts

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

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.

DescriptionCharacteristics
Server stateData from APIs, databases, or external sourcesCan become stale, needs caching, requires synchronization
Client stateUI state, form inputs, theme preferencesLives entirely in the browser
Derived stateComputed from other stateLike 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 APIReact Query (or SWR)User profiles, product listings, API responses
Shareable/bookmarkableURL StateSearch queries, filters, pagination, view modes
Component-specificuseStateForm inputs, toggle states, local UI state
Simple & rarely changesContext APITheme, simple auth, app configuration
Needs atomic/composable stateJotai (or Recoil)Complex derived state, fine-grained reactivity
Already using ReduxRedux ToolkitLegacy codebases, existing Redux apps
Global client state (default)ZustandShopping carts, modals, user preferences

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 forAvoid when
Existing Redux codebasesNew projects (use Zustand + React Query)
Large teams with strict patternsSimple applications
Complex state machinesSmall teams
Time-travel debugging needsServer state (use React Query)
Legacy codebasesWhen 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 forAvoid when
Most client state needsServer state (use React Query)
When Redux feels like overkillNeed 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 forAvoid when
Complex derived stateSimple state (use Zustand or local state)
Fine-grained controlSingle store model preference
Applications with many independent state piecesTeam 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 forAvoid when
Any data from an APIClient-only state (use Zustand or local state)
Data that needs to stay freshSimple static data
Complex data fetching logicData 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 forAvoid when
Simple theme/configurationFrequently updating state
Authentication state (if simple)Complex state logic
Small apps with minimal stateNeed for middleware/persistence
State that doesn't change oftenPerformance-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 forAvoid when
Search queries and filtersForm inputs (unless you want them in URL)
Pagination stateUI-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 refreshTemporary 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 useDebouncedCallback to 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

FeatureReduxZustandJotaiReact QueryContext APIURL State
Bundle Size~12KB~1KB~3KB~13KB0KB (built-in)0KB (built-in)
BoilerplateHighLowLowLowLowLow
Learning CurveSteepEasyMediumMediumEasyEasy
DevToolsExcellentGoodGoodExcellentNoneBrowser DevTools
TypeScriptGoodExcellentExcellentExcellentGoodGood
Server State⚠️
Client State✅ (shareable)
Shareable
Bookmarkable
PerformanceGoodExcellentExcellentExcellentPoor (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)

For most applications, I use this combination:

  1. React Query for all server state

    • API calls, data fetching, caching
    • Handles loading/error states automatically
  2. URL State for shareable state

    • Search queries, filters, pagination
    • View modes, sort order
    • Anything that should be bookmarkable or shareable
  3. Zustand for global client state

    • Theme, user preferences, UI state
    • Shopping carts, modals, navigation
    • State that doesn't need to be in URL
  4. 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