Originally published on NextFuture
Introduction: Why State Management Still Matters
State management is the backbone of every React application. Whether you're building a simple todo app or a complex enterprise dashboard, how you manage state determines your app's performance, maintainability, and developer experience.
In 2026, the React state management landscape has matured significantly. The "Redux or nothing" era is long gone. Today, developers have a rich ecosystem of purpose-built tools — from lightweight atoms to powerful server-state managers. But with great choice comes great confusion.
This ultimate guide cuts through the noise. You'll learn when to use each approach, see real-world code examples, understand the trade-offs, and walk away with a clear mental model for choosing the right state management strategy for any React project in 2026.
Table of Contents
Local State: useState and useReducer
Before reaching for any library, remember that React's built-in hooks handle the majority of state management needs. The useState hook is your go-to for simple, component-scoped state — toggle buttons, form inputs, UI visibility flags.
For more complex state logic with multiple sub-values or when the next state depends on the previous one, useReducer provides a structured approach inspired by the Redux pattern, but without the boilerplate.
When to use useState
Simple boolean toggles (modals, dropdowns, tabs)
Single form field values
Counter-style state
State that doesn't need to be shared across components
When to upgrade to useReducer
State with multiple related sub-values
Complex update logic with many possible transitions
When you want to centralize state logic for testing
import { useReducer } from 'react';
// Define your state shape and actions with TypeScript
interface CartState {
items: Array;
discount: number;
isCheckingOut: boolean;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: { id: string; name: string; price: number } }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'APPLY_DISCOUNT'; payload: { discount: number } }
| { type: 'START_CHECKOUT' }
| { type: 'RESET' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(item => item.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
),
};
case 'APPLY_DISCOUNT':
return { ...state, discount: action.payload.discount };
case 'START_CHECKOUT':
return { ...state, isCheckingOut: true };
case 'RESET':
return { items: [], discount: 0, isCheckingOut: false };
default:
return state;
}
}
// Usage in a component
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
discount: 0,
isCheckingOut: false,
});
const total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const finalTotal = total * (1 - state.discount);
return (
{state.items.map(item => (
{item.name} x{item.quantity}
dispatch({
type: 'UPDATE_QUANTITY',
payload: { id: item.id, quantity: item.quantity + 1 }
})}>+
dispatch({
type: 'REMOVE_ITEM',
payload: { id: item.id }
})}>Remove
))}
Total: ${finalTotal.toFixed(2)}
);
}
Key insight: The reducer pattern keeps all your state transitions in one place. This makes debugging easier — you can log every action, time-travel through state changes, and write unit tests for the reducer without rendering any components.
The Context API: When It Works (and When It Doesn't)
React Context is often the first tool developers reach for when they need to share state across components. It's built into React, requires no extra dependencies, and the API is straightforward. But Context has a critical limitation that trips up even experienced developers: every consumer re-renders when the context value changes, regardless of which part of the value they actually use.
The re-render problem
Consider a theme context that provides both the current theme and a toggle function. If you update the theme, every component consuming that context re-renders — even components that only use the toggle function and don't care about the theme value itself.
import { createContext, useContext, useState, useMemo, ReactNode } from 'react';
// ANTI-PATTERN: One big context causes unnecessary re-renders
// const AppContext = createContext({ theme: 'light', user: null, locale: 'en' });
// BETTER: Split contexts by update frequency
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light');
// Memoize to prevent unnecessary re-renders of consumers
const value = useMemo(() => ({
theme,
toggleTheme: () => setTheme(prev => prev === 'light' ? 'dark' : 'light'),
}), [theme]);
return (
{children}
);
}
// Custom hook with proper error handling
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// Separate context for user data (updates independently of theme)
interface UserContextType {
user: { id: string; name: string; email: string } | null;
login: (email: string, password: string) => Promise;
logout: () => void;
}
const UserContext = createContext(null);
When Context is the right choice
Theming: theme values change rarely and affect many components
Locale/i18n: language settings are near-static
Auth state: current user data accessed throughout the app
Feature flags: read-mostly configuration
When Context is NOT the right choice
Frequently updating state (animations, real-time data, form inputs)
Complex state with many consumers that each need different slices
When you need performance optimizations like selectors
Rule of thumb: If your state updates more than once per second or you need selective subscriptions, Context alone won't cut it. That's where dedicated state management libraries shine.
Zustand: The Minimalist Powerhouse
Zustand (German for "state") has become the most popular external state management library in the React ecosystem, and for good reason. It's tiny (1.2 KB gzipped), requires zero boilerplate, supports selectors out of the box, and works seamlessly with React's concurrent features.
Unlike Context, Zustand only re-renders components when the specific slice of state they subscribe to changes. This makes it incredibly efficient for medium to large applications.
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
// Define your store with TypeScript
interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: Date;
}
interface TodoStore {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
// Actions
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
setFilter: (filter: 'all' | 'active' | 'completed') => void;
clearCompleted: () => void;
// Computed (derived) values as getters
filteredTodos: () => Todo[];
stats: () => { total: number; active: number; completed: number };
}
export const useTodoStore = create()(
devtools(
persist(
immer((set, get) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set((state) => {
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date(),
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}),
removeTodo: (id) =>
set((state) => {
state.todos = state.todos.filter((t) => t.id !== id);
}),
setFilter: (filter) => set({ filter }),
clearCompleted: () =>
set((state) => {
state.todos = state.todos.filter((t) => !t.completed);
}),
filteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case 'active': return todos.filter((t) => !t.completed);
case 'completed': return todos.filter((t) => t.completed);
default: return todos;
}
},
stats: () => {
const todos = get().todos;
return {
total: todos.length,
active: todos.filter((t) => !t.completed).length,
completed: todos.filter((t) => t.completed).length,
};
},
})),
{ name: 'todo-store' } // localStorage key for persistence
),
{ name: 'TodoStore' } // Redux DevTools label
)
);
// In components — only re-renders when the selected slice changes
function TodoCount() {
const active = useTodoStore((state) => state.stats().active);
return {active} items left;
}
function FilterButtons() {
const filter = useTodoStore((state) => state.filter);
const setFilter = useTodoStore((state) => state.setFilter);
return (
{(['all', 'active', 'completed'] as const).map((f) => (
setFilter(f)}
className={filter === f ? 'active' : ''}
>
{f}
))}
);
}
Zustand's middleware system is what elevates it from "simple" to "production-ready." The devtools middleware connects to Redux DevTools. The persist middleware syncs state to localStorage automatically. And the immer middleware lets you write mutable-style updates that produce immutable state underneath.
Why Zustand wins in 2026
No providers needed: unlike Context or Redux, Zustand stores work outside React's component tree
Selector-based subscriptions: fine-grained re-rendering without
React.memoTypeScript-first: excellent type inference with minimal annotations
Framework-agnostic core: the vanilla store works with any UI framework
SSR compatible: works with Next.js App Router and server components
Jotai: Atomic State for Fine-Grained Reactivity
If Zustand is a lightweight Redux, Jotai is a lightweight Recoil. It takes an "atomic" approach — you define small, independent pieces of state called atoms, and compose them into derived atoms. Components subscribe to individual atoms, so re-renders are surgically precise.
Jotai excels in applications where state is highly granular and interconnected — think spreadsheet apps, complex forms, or interactive dashboards where dozens of independent values need to stay in sync.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { atomWithQuery } from 'jotai-tanstack-query';
// Primitive atoms — the building blocks
const searchQueryAtom = atom('');
const selectedCategoryAtom = atomWithStorage('category', 'all');
const sortOrderAtom = atom('desc');
const pageAtom = atom(1);
// Derived atom — automatically recomputes when dependencies change
const apiUrlAtom = atom((get) => {
const query = get(searchQueryAtom);
const category = get(selectedCategoryAtom);
const sort = get(sortOrderAtom);
const page = get(pageAtom);
const params = new URLSearchParams({
...(query && { q: query }),
...(category !== 'all' && { category }),
sort,
page: String(page),
});
return `/api/products?${params.toString()}`;
});
// Async atom — fetches data when the URL changes
const productsAtom = atom(async (get) => {
const url = get(apiUrlAtom);
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch products');
return response.json() as Promise;
totalPages: number;
}>;
});
// Write-only atom for complex actions
const resetFiltersAtom = atom(null, (get, set) => {
set(searchQueryAtom, '');
set(selectedCategoryAtom, 'all');
set(sortOrderAtom, 'desc');
set(pageAtom, 1);
});
// Components subscribe to exactly what they need
function SearchBar() {
const [query, setQuery] = useAtom(searchQueryAtom);
return (
setQuery(e.target.value)}
placeholder="Search products..."
/>
);
}
function ProductList() {
const { products } = useAtomValue(productsAtom);
return (
{products.map(p => (
- {p.name} — ${p.price}
))}
);
}
function ResetButton() {
const reset = useSetAtom(resetFiltersAtom);
return Reset Filters;
}
Zustand vs Jotai: Use Zustand when you have clearly defined stores (user store, cart store, notification store). Use Jotai when state is scattered and interconnected — where defining "stores" feels forced and you'd rather compose small atoms together.
TanStack Query: Server State Done Right
Here's the insight that changed how the React community thinks about state: most of your "global state" is actually server state. User data, product lists, notifications, dashboard metrics — all of this originates from an API. Managing it with client-side state tools creates a cascade of problems: stale data, loading states, error handling, cache invalidation, and race conditions.
TanStack Query (formerly React Query) solves all of this with a declarative, cache-first approach. It handles fetching, caching, synchronization, and garbage collection automatically. In 2026, TanStack Query v6 is the undisputed standard for server state in React applications.
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
// API layer — keep it separate from your hooks
const api = {
getUsers: async (page: number): Promise => {
const res = await fetch(`/api/users?page=${page}&limit=20`);
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
},
updateUser: async (id: string, data: Partial): Promise => {
const res = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to update user');
return res.json();
},
deleteUser: async (id: string): Promise => {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete user');
},
};
// Custom hooks — encapsulate query logic
function useUsers(page: number) {
return useQuery({
queryKey: ['users', { page }],
queryFn: () => api.getUsers(page),
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
placeholderData: (previousData) => previousData, // Show old data while fetching
});
}
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial }) =>
api.updateUser(id, data),
// Optimistic update — UI updates instantly, rolls back on error
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ['users'] });
const previous = queryClient.getQueriesData({ queryKey: ['users'] });
queryClient.setQueriesData(
{ queryKey: ['users'] },
(old: any) => ({
...old,
users: old.users.map((u: User) =>
u.id === id ? { ...u, ...data } : u
),
})
);
return { previous };
},
onError: (_err, _vars, context) => {
// Roll back optimistic update on failure
context?.previous.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
},
onSettled: () => {
// Refetch to ensure consistency regardless of success/failure
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// Usage in components is clean and declarative
function UserTable() {
const [page, setPage] = useState(1);
const { data, isLoading, error } = useUsers(page);
const updateUser = useUpdateUser();
if (isLoading) return ;
if (error) return ;
return (
{data.users.map(user => (
updateUser.mutate({ id: user.id, data })}
/>
))}
);
}
The real power of TanStack Query is what you don't have to build: loading spinners, error boundaries, retry logic, cache invalidation, race condition handling, pagination state, background refetching, and optimistic updates. It handles all of this out of the box.
Redux Toolkit: When You Actually Need It
Redux gets a bad reputation in 2026, and honestly, much of it is deserved — the old-school boilerplate-heavy Redux was painful. But Redux Toolkit (RTK) transformed Redux into a modern, enjoyable tool. The question isn't whether Redux Toolkit is good (it is), but whether you need it.
You probably need Redux Toolkit if:
Your app has complex, interconnected client-side state (think Figma, Notion, or a code editor)
You need robust middleware for side effects (RTK Listeners, sagas)
Your team needs strong architectural conventions enforced by tooling
You want a mature ecosystem (DevTools, testing utilities, documentation)
You're building an app where time-travel debugging is genuinely useful
You probably don't need Redux Toolkit if:
Most of your state is server state (use TanStack Query instead)
You have a handful of simple global values (use Zustand)
You're building a smaller app or prototype
Your team is small and doesn't need enforced conventions
If you do reach for Redux Toolkit, the modern API is genuinely pleasant to use. Slices combine actions and reducers. RTK Query handles API caching (similar to TanStack Query). And the DevTools integration remains best-in-class.
URL State: The Most Underrated Pattern
Here's a pattern that deserves far more attention: using the URL as your state manager. Search filters, pagination, sorting, tab selection, modal state — all of this can (and often should) live in the URL.
Why? Because URL state is shareable (users can bookmark or share filtered views), persistent (survives page refreshes), and free (no libraries needed). In Next.js App Router, the useSearchParams hook makes this straightforward.
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useCallback, useMemo } from 'react';
// Generic hook for managing URL search params as state
function useQueryState>(defaults: T) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// Read current values from URL, falling back to defaults
const state = useMemo(() => {
const result = { ...defaults };
for (const key of Object.keys(defaults)) {
const value = searchParams.get(key);
if (value !== null) {
(result as any)[key] = value;
}
}
return result;
}, [searchParams, defaults]);
// Update URL params without full page reload
const setState = useCallback(
(updates: Partial) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === defaults[key] || value === '' || value === undefined) {
params.delete(key); // Remove default values to keep URLs clean
} else {
params.set(key, value as string);
}
}
const query = params.toString();
router.push(`${pathname}${query ? `?${query}` : ''}`, { scroll: false });
},
[searchParams, pathname, router, defaults]
);
return [state, setState] as const;
}
// Usage: Product listing page with filters in the URL
function ProductFilters() {
const [filters, setFilters] = useQueryState({
q: '',
category: 'all',
sort: 'newest',
page: '1',
});
return (
setFilters({ q: e.target.value, page: '1' })}
placeholder="Search..."
/>
setFilters({ sort: e.target.value })}
>
Newest
Price: Low to High
Price: High to Low
);
}
// The URL becomes: /products?q=keyboard&sort=price-asc&page=2
// Users can bookmark, share, and navigate back/forward through filter states
Pro tip: Combine URL state with TanStack Query. Use the URL params as part of your query key, and TanStack Query automatically refetches when the URL changes. This gives you shareable, cached, automatically-synced data fetching — the holy grail of frontend data management.
Form State: React Hook Form and Beyond
Form state is a special category. It's temporary, highly interactive, and has unique requirements like validation, dirty checking, and field-level error tracking. Using a general-purpose state manager for forms is like using a bulldozer to plant flowers — it works, but there are better tools.
React Hook Form remains the gold standard in 2026. It uses uncontrolled components by default (refs instead of state), which means fewer re-renders and better performance — especially in forms with dozens of fields.
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Define validation schema with Zod
const projectSchema = z.object({
name: z.string().min(3, 'Project name must be at least 3 characters'),
description: z.string().max(500, 'Description too long').optional(),
budget: z.number().min(0, 'Budget cannot be negative'),
deadline: z.string().refine((d) => new Date(d) > new Date(), {
message: 'Deadline must be in the future',
}),
team: z.array(z.object({
name: z.string().min(1, 'Name is required'),
role: z.enum(['developer', 'designer', 'pm', 'qa']),
email: z.string().email('Invalid email'),
})).min(1, 'At least one team member is required'),
});
type ProjectForm = z.infer;
function CreateProjectForm() {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
reset,
} = useForm
({
resolver: zodResolver(projectSchema),
defaultValues: {
name: '',
description: '',
budget: 0,
deadline: '',
team: [{ name: '', role: 'developer', email: '' }],
},
});
// Dynamic field arrays for team members
const { fields, append, remove } = useFieldArray({
control,
name: 'team',
});
const onSubmit = async (data: ProjectForm) => {
await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
reset(); // Clear form after successful submission
};
return (
{errors.name && {errors.name.message}}
{errors.budget && {errors.budget.message}}
### Team Members
{fields.map((field, index) => (
Developer
Designer
PM
QA
{fields.length > 1 && (
remove(index)}>Remove
)}
))}
append({ name: '', role: 'developer', email: '' })}>
Add Team Member
{isSubmitting ? 'Creating...' : 'Create Project'}
);
}
How to Choose: A Decision Framework
After covering all the options, here's a practical decision framework you can apply to any React project:
Is it server data? → TanStack Query. Don't manage API responses with client-side state tools. Period.
Is it form data? → React Hook Form. Specialized tools beat general-purpose ones for forms.
Is it URL-representable? → URL state (useSearchParams). Filters, pagination, tabs, and modal state belong in the URL.
Is it component-local? → useState or useReducer. Don't over-engineer what doesn't need engineering.
Is it shared but simple? → Zustand. A single store with selectors handles most global UI state.
Is it shared and granular? → Jotai. When you need fine-grained reactivity across many atoms.
Is it complex with strict architecture needs? → Redux Toolkit. The heavyweight champion for complex apps.
Is it read-mostly configuration? → React Context. Theme, locale, feature flags.
The golden rule: Use the simplest tool that solves your problem. Most React apps need TanStack Query + Zustand + URL state. That covers 95% of use cases without any over-engineering.
Common Mistakes and How to Avoid Them
Mistake 1: Putting everything in global state
Not every piece of state needs to be global. A modal's open/closed state, a dropdown's selected value, a form's input values — these are local concerns. Lifting state to global stores creates unnecessary coupling and makes components harder to reuse. Fix: Default to local state. Only promote to global when multiple unrelated components need the same data.
Mistake 2: Duplicating server state in client stores
The most common anti-pattern in 2026: fetching data from an API, storing it in Zustand or Redux, then manually keeping it in sync. This leads to stale data, complex synchronization logic, and bugs that are nearly impossible to reproduce. Fix: Use TanStack Query for any data that originates from a server. It handles caching, revalidation, and garbage collection automatically.
Mistake 3: Ignoring re-render performance
Using React Context for frequently-updated state causes every consumer to re-render on every change. With dozens of components subscribing to a single context, this creates noticeable jank. Fix: Split contexts by update frequency, or switch to Zustand/Jotai which provide selector-based subscriptions.
Mistake 4: Over-engineering simple apps
A landing page or a simple CRUD app doesn't need Redux Toolkit, a normalized entity store, or a complex middleware pipeline. Over-engineering slows down development, confuses new team members, and creates maintenance burden. Fix: Start with useState and TanStack Query. Add complexity only when you feel the pain of not having it.
Mistake 5: Not using TypeScript with your state
Untyped state is a time bomb. It works fine during initial development, then explodes during refactoring when you rename a property and miss three consumers. Fix: Always define your state interfaces with TypeScript. Libraries like Zustand, Jotai, and TanStack Query all have excellent TypeScript support.
Tools and Resources
Zustand — The most popular lightweight state manager. Start here for global client state.
Jotai — Atomic state management for fine-grained reactivity patterns.
TanStack Query v6 — The definitive solution for server state, caching, and data synchronization.
Redux Toolkit — Modern Redux for complex applications that need strict architecture.
React Hook Form — Performance-focused form state management with built-in validation.
Zod — TypeScript-first schema validation, pairs perfectly with React Hook Form.
React DevTools — Essential for profiling re-renders and debugging state changes.
Ranked.ai — Monitor how your React SPA performs in search engines. State management patterns directly affect rendering and SEO — Ranked.ai helps you track keyword rankings and ensure your pages are discoverable.
FAQ
Is Redux dead in 2026?
No, but its role has narrowed significantly. Redux Toolkit is still the right choice for complex, highly-interconnected client state in large applications. However, for most projects, Zustand + TanStack Query provides a simpler, equally powerful combination. Redux isn't dead — it's just no longer the default.
Should I use Zustand or Jotai?
Use Zustand when you think in terms of "stores" — cohesive collections of related state and actions (user store, cart store). Use Jotai when you think in terms of "atoms" — many small, independent pieces of state that compose together. For most apps, Zustand's mental model is more intuitive.
Do I need a state management library for a small app?
Probably not. React's built-in useState, useReducer, and Context API handle small to medium apps perfectly well. Add TanStack Query for server state, and you've covered most needs without any external state library. Introduce Zustand or Jotai when prop drilling becomes painful or you need cross-component shared state.
How do I handle state in Next.js App Router with Server Components?
Server Components can't use hooks or client-side state. Fetch data directly in Server Components using async/await. Pass the data down as props to Client Components that manage local UI state. For shared client state, use Zustand stores (they work outside the component tree) or Jotai atoms in Client Components only.
What's the best way to persist state across page reloads?
It depends on the data. URL search params are ideal for filters, pagination, and shareable state. For user preferences or draft content, use Zustand's persist middleware (syncs to localStorage automatically). For server data, TanStack Query's cache persists across navigations and can be hydrated from the server. Avoid manually serializing state to localStorage — let your tools handle it.
Conclusion: Key Takeaways
React state management in 2026 isn't about picking one tool — it's about using the right tool for each type of state. Here's the practical takeaway:
Server state: TanStack Query. Always. No exceptions.
Form state: React Hook Form + Zod for validation.
URL state: useSearchParams for anything shareable or bookmarkable.
Local UI state: useState and useReducer handle the majority.
Global client state: Zustand for most apps. Jotai when you need atomic granularity.
Complex enterprise state: Redux Toolkit when you need the architecture.
The best state architecture is invisible. Your users don't care how you manage state — they care that the app is fast, responsive, and reliable. Choose tools that let you focus on building features, not fighting your state layer.
Start simple, add complexity only when needed, and always type your state with TypeScript. Your future self will thank you.
{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is Redux dead in 2026?","acceptedAnswer":{"@type":"Answer","text":"No, but its role has narrowed. Redux Toolkit is still the right choice for complex, highly-interconnected client state in large applications. However, for most projects, Zustand + TanStack Query provides a simpler, equally powerful combination."}},{"@type":"Question","name":"Should I use Zustand or Jotai?","acceptedAnswer":{"@type":"Answer","text":"Use Zustand when you think in terms of stores — cohesive collections of related state and actions. Use Jotai when you think in terms of atoms — many small, independent pieces of state that compose together. For most apps, Zustand's mental model is more intuitive."}},{"@type":"Question","name":"Do I need a state management library for a small app?","acceptedAnswer":{"@type":"Answer","text":"Probably not. React's built-in useState, useReducer, and Context API handle small to medium apps perfectly well. Add TanStack Query for server state, and you've covered most needs without any external state library."}},{"@type":"Question","name":"How do I handle state in Next.js App Router with Server Components?","acceptedAnswer":{"@type":"Answer","text":"Server Components can't use hooks or client-side state. Fetch data directly in Server Components using async/await. Pass the data down as props to Client Components that manage local UI state. For shared client state, use Zustand stores or Jotai atoms in Client Components only."}},{"@type":"Question","name":"What is the best way to persist state across page reloads?","acceptedAnswer":{"@type":"Answer","text":"It depends on the data. URL search params are ideal for filters and shareable state. Zustand's persist middleware syncs to localStorage automatically. TanStack Query's cache persists across navigations. Avoid manually serializing state to localStorage — let your tools handle it."}}]}
This article was originally published on NextFuture. Follow us for more fullstack & AI engineering content.
Top comments (0)