How to manage state in modern frontend applications — a practical guide
Frontend State Management: A Practical Guide for 2026
React's state management landscape has fragmented into specialized solutions. The key to avoiding over-engineering is understanding that not all state is the same. This guide covers local state, server state, URL state, and app state-with real code examples showing when to use each pattern.
The State Management Spectrum
Before choosing a tool, categorize your state:
| State Type | Characteristics | Best Tool |
|---|---|---|
| Local state | Form inputs, UI toggles, ephemeral data |
useState/useReducer
|
| Server state | Async API data, needs caching/mutations | TanStack Query (React Query) |
| Global UI state | Themes, modals, sidebars, auth | Zustand or Context API |
| URL state | Filters, pagination, search params |
nuqs or TanStack Router |
| Complex global state | Dozens of actions, time-travel debugging | Redux Toolkit |
Local State: useState and useReducer
Use local state for anything contained within a single component.
Form Inputs and UI Toggles
import { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // typing | submitting | success
if (status === 'success') {
return <h1>Login successful!</h1>;
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await submitForm(email, password);
setStatus('success');
} catch (err) {
setError(err);
setStatus('typing');
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={e => setEmail(e.target.value)}
disabled={status === 'submitting'}
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={status === 'submitting'}
/>
{error && <p className="Error">{error.message}</p>}
<button disabled={!email || !password || status === 'submitting'}>
Submit
</button>
</form>
);
}
Key principles:
-
Avoid redundant state: Don't store
fullNameif you havefirstNameandlastName-calculate it during render - Keep state local: Only lift state up when multiple components need it
Complex Local State with useReducer
When you have many related state updates, consolidate logic in a reducer:
import { useReducer } from 'react';
function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({ type: 'added', id: nextId++, text });
}
function handleChangeTask(task) {
dispatch({ type: 'changed', task });
}
function handleDeleteTask(taskId) {
dispatch({ type: 'deleted', id: taskId });
}
return (
<>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added':
return [...tasks, { id: action.id, text: action.text, done: false }];
case 'changed':
return tasks.map(t =>
t.id === action.task.id ? action.task : t
);
case 'deleted':
return tasks.filter(t => t.id !== action.id);
default:
throw Error('Unknown action: ' + action.type);
}
}
This pattern makes state updates predictable and testable.
Server State: TanStack Query (React Query)
Stop putting API data in Redux. Server state is fundamentally different-it's async, needs caching, and requires mutation patterns. TanStack Query handles all of this automatically.
Basic Query with Caching
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Cache time (formerly cacheTime)
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorComponent error={error} />;
return <Profile user={data} />;
}
Mutations with Optimistic Updates
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UpdateUser({ userId }) {
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: updateUser,
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot previous value
const previous = queryClient.getQueryData(['user', userId]);
// Optimistically update UI
queryClient.setQueryData(['user', userId], newData);
return { previous };
},
onError: (err, newData, context) => {
// Rollback on error
queryClient.setQueryData(['user', userId], context.previous);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
return (
<button onClick={() => updateMutation.mutate({ name: 'New Name' })}>
Update Name
</button>
);
}
TanStack Query provides caching, deduplication, background refetches, and optimistic updates out of the box.
Global UI State: Zustand vs Context API
When to Use Context API
Context is built into React and works well for:
- Theme (dark/light mode)
- User authentication status
- Language settings (i18n)
- Settings read often but written rarely
Bad use cases for Context:
- Frequently updated data (real-time feeds, chat)
- Complex state with many actions
- Performance-critical updates that change on every render
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Usage
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme} style={{ background: theme === 'light' ? '#fff' : '#333' }}>
Toggle to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
}
Zustand: Simpler, Faster, More Flexible
Zustand is the modern choice for global UI state. It has minimal boilerplate, no providers needed, and built-in middleware for persistence and devtools.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const useStore = create(
devtools(
persist(
(set, get) => ({
user: null,
cart: [],
setUser: (user) => set({ user }),
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
total: () => get().cart.reduce((sum, item) => sum + item.price, 0),
clearCart: () => set({ cart: [] })
}),
{
name: 'app-storage',
part: ['user', 'cart'] // Only persist these fields
}
)
)
);
// Usage - no providers needed!
function CartButton() {
const { cart, total } = useStore();
return <button>{cart.length} items (${total()})</button>;
}
function UserProfile() {
const { user, setUser } = useStore();
const handleLogin = (userData) => {
setUser(userData);
};
return user ? <div>Welcome, {user.name}</div> : <button onClick={() => handleLogin({ name: 'John' })}>Login</button>;
}
Zustand vs Redux comparison:
- Redux: Structured and powerful-best for very large, complex applications with dozens of actions
- Zustand: Minimal and fast-best for small/medium projects or when you want to move quickly without boilerplate
URL State: Shareable, Bookmarkable State
URL params are global, serializable, and shareable. Use them for filters, pagination, and search-anything users should be able to bookmark or share.
Using nuqs for URL State
import { useQueryState } from 'nuqs';
function ProductList() {
const [category, setCategory] = useQueryState('category');
const [sort, setSort] = useQueryState('sort', { defaultValue: 'price-asc' });
const [page, setPage] = useQueryState('page', {
defaultValue: '1',
parse: Number,
serialize: String
});
const [search, setSearch] = useQueryState('search');
// URL is now: /products?category=electronics&sort=price-asc&page=2&search=laptop
// Shareable, bookmarkable, back-button aware
const filteredProducts = products.filter(product => {
if (category && product.category !== category) return false;
if (search && !product.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const sortedProducts = filteredProducts.sort((a, b) => {
if (sort === 'price-asc') return a.price - b.price;
if (sort === 'price-desc') return b.price - a.price;
return 0;
});
return (
<>
<select value={category || ''} onChange={e => setCategory(e.target.value || null)}>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select value={sort} onChange={e => setSort(e.target.value)}>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
<input
placeholder="Search..."
value={search || ''}
onChange={e => setSearch(e.target.value || null)}
/>
<div>
{sortedProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Pagination currentPage={page} onPageChange={setPage} />
</>
);
}
The URL becomes the source of truth for filters-users can copy the URL, share it, or bookmark it, and they'll get the exact same view.
Choosing the Right Approach
Decision Matrix
| Situation | Use This Approach |
|---|---|
| Form inputs, UI toggles | Local (useState) |
| Theme, auth, simple settings | Context API |
| Multiple components write frequently | Zustand/Jotai |
| Complex state with many actions | Redux Toolkit |
| Need undo/redo/time-travel | Redux Toolkit |
| Performance critical with many updates | Zustand or Jotai |
| API data, caching | TanStack Query |
| Theme, modals | Zustand or Context |
| Forms | React Hook Form |
| URL params | nuqs |
| Animation state |
useState + refs |
| Complex global state | Zustand or Redux Toolkit |
When to Avoid Redux
Stop using Redux for everything. Only reach for Redux Toolkit when you have genuinely complex client-side logic that Zustand can't handle:
- Very complex state (dozens of actions, reducers)
- Need time-travel debugging
- Team already knows Redux
- Need middleware (thunk, saga)
- Want large ecosystem support
For most applications in 2026:
- TanStack Query handles server state
- Zustand handles global UI state
-
useStatehandles local state
Avoiding Over-Engineering
Common Mistakes
- Using Redux for everything: TanStack Query should handle API data, not Redux
-
Storing derived data in state: Calculate
fullNamefromfirstName+lastNameduring render instead of storing it - Putting server state in global stores: API data belongs in TanStack Query's cache, not Zustand/Redux
- Not using URL state for filters: Filters and pagination should be in the URL for shareability
- Creating global state for local things: Keep state as local as possible-only lift when needed
Keep It Simple Principles
-
Start with built-in tools: Use
useStateanduseContextbefore introducing external libraries - Separate concerns: Distinguish between local and global states-not all state needs to be shared
- Predictable updates: Use immutable updates to prevent unexpected side effects
- Leverage DevTools: Use Redux DevTools, Zustand DevTools, or React DevTools to debug
Testing State Logic
Testing Reducers
Reducers are pure functions-easy to test:
// tasksReducer.test.js
import { describe, it, expect } from 'vitest';
import { tasksReducer } from './tasksReducer';
const initialTasks = [
{ id: 0, text: 'Task 1', done: false },
{ id: 1, text: 'Task 2', done: true }
];
describe('tasksReducer', () => {
it('adds a new task', () => {
const newState = tasksReducer(initialTasks, {
type: 'added',
id: 2,
text: 'New Task'
});
expect(newState).toHaveLength(3);
expect(newState).toEqual({ id: 2, text: 'New Task', done: false });
});
it('deletes a task', () => {
const newState = tasksReducer(initialTasks, {
type: 'deleted',
id: 0
});
expect(newState).toHaveLength(1);
expect(newState.id).toBe(1);
});
it('changes a task', () => {
const newState = tasksReducer(initialTasks, {
type: 'changed',
task: { id: 0, text: 'Updated Task 1', done: true }
});
expect(newState.text).toBe('Updated Task 1');
expect(newState.done).toBe(true);
});
});
Testing Zustand Stores
// useStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { useStore } from './useStore';
describe('Cart Store', () => {
beforeEach(() => {
// Reset store before each test
useStore.setState({ cart: [], user: null });
});
it('adds items to cart', () => {
const item = { id: 1, name: 'Book', price: 10 };
useStore.getState().addToCart(item);
expect(useStore.getState().cart).toHaveLength(1);
expect(useStore.getState().cart).toEqual(item);
});
it('calculates total correctly', () => {
useStore.setState({
cart: [
{ id: 1, price: 10 },
{ id: 2, price: 20 }
]
});
expect(useStore.getState().total()).toBe(30);
});
it('clears cart', () => {
useStore.setState({ cart: [{ id: 1, price: 10 }] });
useStore.getState().clearCart();
expect(useStore.getState().cart).toHaveLength(0);
});
});
Testing TanStack Query
// UserProfile.test.js
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import UserProfile from './UserProfile';
const createQueryClient = () =>
new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderWithQueryClient(ui, { queryClient = createQueryClient() } = {}) {
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}
describe('UserProfile', () => {
it('shows loading state', () => {
const queryClient = createQueryClient();
renderWithQueryClient(<UserProfile userId={1} />, { queryClient });
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('fetches and displays user data', async () => {
const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
renderWithQueryClient(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
it('shows error state', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Failed to fetch'));
renderWithQueryClient(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Complete Real-World Example: E-Commerce Dashboard
Here's how all these patterns work together in a real application:
// App.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, useTheme } from './ThemeContext';
import ProductList from './ProductList';
import CartButton from './CartButton';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Dashboard />
</ThemeProvider>
</QueryClientProvider>
);
}
function Dashboard() {
const { theme } = useTheme();
return (
<div style={{ background: theme === 'dark' ? '#1a1a1a' : '#fff' }}>
<header>
<h1>Shop</h1>
<CartButton />
</header>
<ProductList />
</div>
);
}
export default App;
// ProductList.jsx - URL state + Server state
import { useQueryState } from 'nuqs';
import { useQuery } from '@tanstack/react-query';
function ProductList() {
const [category, setCategory] = useQueryState('category');
const [sort, setSort] = useQueryState('sort', { defaultValue: 'price-asc' });
const [page, setPage] = useQueryState('page', {
defaultValue: '1',
parse: Number,
serialize: String
});
// Server state: API data with caching
const { data: products, isLoading } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
staleTime: 5 * 60 * 1000,
});
// Local state: ephemeral UI state
const [searchTerm, setSearchTerm] = useState('');
if (isLoading) return <LoadingSpinner />;
const filtered = products
.filter(p => !searchTerm || p.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => {
if (sort === 'price-asc') return a.price - b.price;
if (sort === 'price-desc') return b.price - a.price;
return 0;
});
return (
<>
<input
placeholder="Search..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} // Local state
/>
<select value={category || ''} onChange={e => setCategory(e.target.value || null)}>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
</select>
<div>
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</>
);
}
// CartButton.jsx - Zustand global state
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useCartStore = create(
persist(
(set, get) => ({
cart: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
removeFromCart: (itemId) => set((state) => ({
cart: state.cart.filter(item => item.id !== itemId)
})),
total: () => get().cart.reduce((sum, item) => sum + item.price, 0)
}),
{ name: 'cart-storage' }
)
);
function CartButton() {
const { cart, total } = useCartStore();
return (
<button>
Cart ({cart.length}) - ${total()}
</button>
);
}
Summary
The right state management approach depends on the type of state:
-
Local state (
useState): Form inputs, UI toggles, component-specific data - Server state (TanStack Query): API data, caching, mutations
- Global UI state (Zustand): Themes, modals, cart, auth
-
URL state (
nuqs): Filters, pagination, search-anything shareable - Complex state (Redux Toolkit): Only when Zustand can't handle it
Key takeaways:
- Stop using Redux for everything-TanStack Query handles server state, Zustand handles global UI state
- Keep state as local as possible-only lift when multiple components need it
- Use URL state for filters and pagination-makes your app shareable and bookmarkable
- Avoid redundant state-calculate derived values during render
- Test your state logic-reducers and stores are pure functions that are easy to unit test
Build simpler, faster apps by choosing the right tool for each type of state.
Rizwan Saleem — https://rizwansaleem.co
Top comments (0)