React State Management in 2025: Choosing the Right Approach
A practical guide to React state management options in 2025. Compare useState, useReducer, Context, Zustand, and server state with real-world use cases and performance considerations.
State management in React has evolved significantly. With React Server Components, improved Context, and mature third-party libraries, choosing the right approach is more nuanced than ever. Here's what I've learned building production applications like Prop Monkey and InsureSignal.
If you're new to TypeScript with React, start with my TypeScript best practices guide first.
The State Management Spectrum
Local State ────────────────────────────────────► Global State
│ │
useState useReducer Context Zustand/Jotai Redux
│ │
Simple Complex Prop Complex Enterprise
Component Component Drilling Client State Apps
State Logic Solution Management
1. useState: The Foundation
For component-local state, useState is still king:
function SearchInput() {
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const handleSearch = async () => {
setIsSearching(true);
await performSearch(query);
setIsSearching(false);
};
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={isSearching}
/>
<button onClick={handleSearch} disabled={isSearching}>
{isSearching ? 'Searching...' : 'Search'}
</button>
</div>
);
}
When to use: Single component state, form inputs, UI toggles, local loading states.
2. useReducer: Complex Component Logic
When state updates become interdependent:
interface FormState {
values: Record<string, string>;
errors: Record<string, string>;
touched: Record<string, boolean>;
isSubmitting: boolean;
}
type FormAction =
| { type: 'SET_VALUE'; field: string; value: string }
| { type: 'SET_ERROR'; field: string; error: string }
| { type: 'TOUCH_FIELD'; field: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_VALUE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: '' },
};
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case 'TOUCH_FIELD':
return {
...state,
touched: { ...state.touched, [action.field]: true },
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_END':
return { ...state, isSubmitting: false };
case 'RESET':
return initialFormState;
default:
return state;
}
}
When to use: Complex forms, multi-step wizards, state machines.
3. Context: Avoiding Prop Drilling
Context is perfect for truly global values that rarely change:
// Theme context - changes infrequently
const ThemeContext = createContext<Theme>('dark');
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// Usage
function Button() {
const { theme } = useContext(ThemeContext);
return <button className={theme === 'dark' ? 'btn-dark' : 'btn-light'} />;
}
Context Performance Gotcha
Context re-renders all consumers when the value changes. Split contexts for different update frequencies:
// Bad - everything re-renders when anything changes
const AppContext = createContext({ user, theme, notifications });
// Good - separate contexts for different update rates
const UserContext = createContext(user); // Rarely changes
const ThemeContext = createContext(theme); // Rarely changes
const NotificationContext = createContext([]); // Changes often
When to use: Theme, auth state, localization, feature flags.
4. Zustand: Lightweight Global State
My go-to for client-side global state. It's simple, performant, and TypeScript-friendly:
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartState {
items: CartItem[];
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
const useCartStore = create<CartState>()(
devtools(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
total: state.total + item.price,
};
}
return {
items: [...state.items, { ...item, quantity: 1 }],
total: state.total + item.price,
};
}),
removeItem: (id) =>
set((state) => {
const item = state.items.find((i) => i.id === id);
return {
items: state.items.filter((i) => i.id !== id),
total: state.total - (item?.price ?? 0) * (item?.quantity ?? 0),
};
}),
clearCart: () => set({ items: [], total: 0 }),
}),
{ name: 'cart-storage' }
)
)
);
// Usage - only re-renders when items change
function CartCount() {
const count = useCartStore((state) => state.items.length);
return <span>{count}</span>;
}
When to use: Shopping carts, UI state (modals, sidebars), user preferences.
5. Server State: TanStack Query
For data from APIs, server state libraries are essential:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetching data
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((r) => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Mutating data
function useAddProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (product: Product) =>
fetch('/api/products', {
method: 'POST',
body: JSON.stringify(product),
}),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// Usage
function ProductList() {
const { data: products, isLoading, error } = useProducts();
const addProduct = useAddProduct();
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<>
{products.map((p) => <ProductCard key={p.id} product={p} />)}
<button onClick={() => addProduct.mutate(newProduct)}>
Add Product
</button>
</>
);
}
When to use: API data, anything that lives on a server.
Decision Matrix
| Scenario | Solution | |----------|----------| | Form input value | useState | | Complex form with validation | useReducer + custom hook | | Theme/auth across app | Context | | Shopping cart | Zustand | | API data | TanStack Query | | Real-time data | TanStack Query + WebSocket | | URL state | nuqs or URL params |
2024 Best Practices
1. Colocate State
Keep state as close to where it's used as possible:
// Bad - lifting state too high
function App() {
const [searchQuery, setSearchQuery] = useState('');
return <DeepNestedSearch query={searchQuery} setQuery={setSearchQuery} />;
}
// Good - state where it's needed
function SearchSection() {
const [query, setQuery] = useState('');
return <SearchInput query={query} setQuery={setQuery} />;
}
2. Derive Don't Store
Calculate values instead of storing them:
// Bad - storing derived state
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
// Must remember to update total whenever items change
// Good - derive the value
const [items, setItems] = useState([]);
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price, 0),
[items]
);
3. Server Components First
With Next.js 13+, fetch data on the server when possible:
// Server Component - no client state needed
async function ProductPage({ id }: { id: string }) {
const product = await db.products.findUnique({ where: { id } });
return <ProductDetails product={product} />;
}
My Stack
For my projects like InsureSignal and Prop Monkey, I use:
- useState/useReducer: Component-level state
- Zustand: Client-side global state (cart, UI)
- TanStack Query: Server state
- URL params: Filters, pagination, search
This combination covers 99% of use cases without the complexity of Redux.
Conclusion
The best state management is the simplest that solves your problem:
- Start with useState
- Upgrade to useReducer for complex logic
- Use Context sparingly for truly global values
- Add Zustand for shared client state
- Use TanStack Query for server data
Don't reach for a global state library until you need it. You'd be surprised how far useState and good component structure can take you.
For more React patterns, check out my blog posts and projects.
Related Articles: