Why State Management Still Matters in 2026
React's component model makes local state trivially easy. useState is one of the most elegant APIs in frontend development. But as applications grow in complexity, state becomes the hardest architectural problem you face. Which data lives where? Who owns it? How does it stay in sync? When does it become stale? These questions don't have universal answers: and that's precisely why the React ecosystem has produced so many solutions.
From 2015 to 2021, the community largely converged on a single answer: Redux. It was robust, predictable, and had excellent DevTools. But it came with significant ceremony (boilerplate, action creators, reducers, selectors), and was systematically overused to manage data that didn't need centralisation. By 2022, a new generation of libraries (Zustand, Jotai, Valtio) emerged to offer lighter alternatives. Simultaneously, TanStack Query and SWR redefined how we think about server data, removing it entirely from client state management.
In 2026, the ecosystem has matured into something more nuanced but also more powerful: different types of state have different tools. This is not fragmentation: it's specialisation. Understanding this shift is the most important mental model in modern React architecture.
Why 2026 Is Different
React Server Components (RSC), introduced in React 18 and now mainstream in Next.js 15, have fundamentally changed the architecture landscape. When a component can fetch its own data on the server without any JavaScript in the browser, a large class of state management problems simply disappears. Server state that previously lived in Redux is now rendered on the server and streamed to the client as HTML.
React 19 further deepened this with use(), improved Suspense semantics, and first-class support for async components. The mental model has shifted from "how do I manage all my data in the browser" to "how little state do I actually need on the client?"
Not All State Is the Same: Know What You're Managing
Before reaching for any library, you need to correctly classify the state you're managing. Each category has distinct characteristics, ownership rules, and optimal tools.
Local UI State
State owned entirely by a single component. Never needs to be shared.
Examples: modal open/close, accordion expanded, tab active, hover state, input focus
useState / useReducerGlobal Client State
Client-only state that multiple components need to read or write. Not persisted on the server.
Examples: authenticated user session, selected theme, sidebar collapsed, shopping cart contents
Zustand / Redux Toolkit / JotaiServer State
Data that lives on the server. The client holds a cached, potentially stale copy. Has its own lifecycle: loading, error, stale, refetching.
Examples: user list from API, product catalogue, notifications, search results
TanStack Query / SWR / RSCURL / Router State
State encoded in the URL. Shareable, bookmarkable, survives refresh. Often overlooked as a state store.
Examples: filters, pagination, search query, sort order, selected entity ID
Next.js router / nuqs / useSearchParamsForm State
Transient input state, validation, field-level error messages, and submission status. Has a highly specific lifecycle.
Examples: registration form, checkout form, settings panel, file upload progress
React Hook Form / FormikWorkflow / Machine State
State that follows a defined set of valid transitions. Prevents impossible states. Sequential or parallel steps.
Examples: checkout flow, onboarding wizard, media player, booking system, auth flow
XState / useReducerDerived State
State computed from other state. Should never be stored: always computed. Stale closures are its biggest enemy.
Examples: cart total, filtered list, formatted display values, computed permissions
useMemo / Jotai atoms / selectorsRealtime Synchronised State
State that must stay in sync across multiple clients in realtime. Requires conflict resolution and offline handling strategies.
Examples: collaborative document, live cursors, shared whiteboard, presence indicators
Legend State / Yjs / PartyKituseState, useReducer, and Context API
Before adding any external library, exhaust React's built-in primitives. They are zero-cost (no bundle), extremely well-typed, and sufficient for most local and low-complexity global state needs.
useState
The most-used hook in any React codebase. Ideal for any state owned by a single component. TypeScript inference works perfectly with generics when the initial value is ambiguous.
type Tab = 'overview' | 'settings' | 'billing'
function Dashboard() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [isModalOpen, setIsModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
return (
<div>
<TabBar active={activeTab} onChange={setActiveTab} />
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</div>
)
}When it scales badly: When you find yourself passing setter functions through 3+ component levels (prop drilling), or lifting the same state multiple times, you have outgrown local state.
useReducer
The natural upgrade from useState when a component has multiple inter-related state values or complex update logic. Gives you a Redux-like pattern at the component level: without any library.
type State = {
count: number
status: 'idle' | 'loading' | 'error'
errorMessage: string | null
}
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'FETCH_START' }
| { type: 'FETCH_ERROR'; message: string }
| { type: 'FETCH_SUCCESS' }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT': return { ...state, count: state.count + 1 }
case 'DECREMENT': return { ...state, count: state.count - 1 }
case 'FETCH_START': return { ...state, status: 'loading', errorMessage: null }
case 'FETCH_ERROR': return { ...state, status: 'error', errorMessage: action.message }
case 'FETCH_SUCCESS': return { ...state, status: 'idle' }
default: return state
}
}
const initialState: State = { count: 0, status: 'idle', errorMessage: null }
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState)
return <button onClick={() => dispatch({ type: 'INCREMENT' })}>{state.count}</button>
}Context API: Use Cases and Pitfalls
Context is React's built-in mechanism for passing data through the component tree without prop drilling. It is not a state manager: it is a transport layer. It does not provide selectors, subscriptions, or performance optimisations. Every consumer re-renders when the context value changes, even if the part they consume hasn't changed.
interface ThemeContextValue {
theme: 'light' | 'dark'
setTheme: (t: 'light' | 'dark') => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
// Memoize to prevent unnecessary rerenders in consumers
const value = useMemo(() => ({ theme, setTheme }), [theme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be inside ThemeProvider')
return ctx
}Context: Good For
- Theme / colour scheme
- Auth session (read-only)
- Locale / i18n config
- Feature flags (stable values)
- Dependency injection (services)
Context: Avoid For
- Frequently updated state (search input, cursor position)
- Large objects with many fields (partial updates cause full rerenders)
- Server data (use TanStack Query)
- Complex global state with many consumers
Prop Drilling and Provider Hell
Prop drillingoccurs when you pass state through intermediate components that don't use it: they exist only to pass it down. It creates coupling, makes components harder to test in isolation, and produces noisy component signatures.
Provider hell is the opposite problem: wrapping your app in many nested context providers, each managing a different domain of state. It becomes hard to understand what context a component has access to and creates a rigid hierarchy. Modern libraries like Zustand solve this by not requiring any provider wrapping at all: stores are imported directly.
Global State Libraries: The 2026 Landscape
When you need state that multiple components across the tree can read and write, and Context is too limited, you need a global state library. In 2026, there are several mature options with distinct philosophies.
Philosophy:"A small, fast, scalable bearbones state management solution." Zustand takes a flux-inspired model but removes almost all the ceremony. You define a store as a function that returns state and actions: nothing more.
Zustand has become the dominant choice for global client state in greenfield React apps in 2026. It requires no provider, works in any component, has excellent TypeScript inference, and its subscription model is granular enough to avoid unnecessary rerenders via selectors.
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface AppStore {
theme: 'light' | 'dark'
sidebarOpen: boolean
user: { id: string; name: string; role: 'admin' | 'user' } | null
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
setUser: (user: AppStore['user']) => void
clearUser: () => void
}
export const useAppStore = create<AppStore>()(
immer((set) => ({
theme: 'dark',
sidebarOpen: true,
user: null,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((s) => { s.sidebarOpen = !s.sidebarOpen }),
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}))
)
// Usage: only re-renders when theme changes
const theme = useAppStore((s) => s.theme)
const user = useAppStore((s) => s.user)Strengths
- Tiny bundle (~1 KB)
- No provider required
- Excellent TypeScript inference
- Granular subscriptions via selectors
- Middleware: immer, devtools, persist
- Works with Next.js SSR carefully
Weaknesses
- No built-in DevTools (need middleware)
- No enforced structure: discipline required
- SSR hydration requires care to avoid mismatches
- Not ideal for very large monolithic stores
useAuthStore, useCartStore, useUIStore) to keep boundaries clear.Philosophy: Opinionated, standardised Redux. RTK eliminates Redux boilerplate while preserving its core guarantees: a single immutable store, serialisable actions, time-travel debugging, and strict unidirectional data flow.
Redux Toolkit is the official way to write Redux in 2026. The classic Redux pattern (separate action types, action creators, reducers) is legacy. RTK's createSlice generates all of that automatically using Immer under the hood. For large enterprise teams with compliance requirements and a need for full action logging, RTK remains the gold standard.
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'
interface CartState {
items: CartItem[]
status: 'idle' | 'loading' | 'failed'
}
export const fetchCart = createAsyncThunk('cart/fetch', async (userId: string) => {
const response = await fetch(`/api/cart/${userId}`)
return response.json() as Promise<CartItem[]>
})
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], status: 'idle' } satisfies CartState,
reducers: {
addItem(state, action: PayloadAction<CartItem>) {
state.items.push(action.payload) // Immer mutation is safe here
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter((i) => i.id !== action.payload)
},
},
extraReducers: (builder) => {
builder
.addCase(fetchCart.pending, (state) => { state.status = 'loading' })
.addCase(fetchCart.fulfilled, (state, action) => {
state.status = 'idle'
state.items = action.payload
})
},
})
export const { addItem, removeItem } = cartSlice.actionsPhilosophy: Primitive and flexible. Jotai models state as individual atoms (the smallest units of state) that can be composed and derived. It was inspired by Recoil but is maintained actively and has much better RSC and SSR support.
Jotai is the de-facto replacement for Recoil. Its atomic model excels when you have many independent pieces of state with complex derivation relationships. Each atom subscription is granular: a component that uses useAtomValue(nameAtom) only re-renders when nameAtom changes, not when any other atom changes.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// Primitive atoms
const userAtom = atom<User | null>(null)
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'dark')
const cartItemsAtom = atom<CartItem[]>([])
// Derived atoms (read-only, computed)
const cartCountAtom = atom((get) => get(cartItemsAtom).length)
const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const displayNameAtom = atom((get) => {
const user = get(userAtom)
return user ? `${user.firstName} ${user.lastName}` : 'Guest'
})
// Writable derived atom (read + write)
const themeToggleAtom = atom(
(get) => get(themeAtom),
(get, set) => set(themeAtom, get(themeAtom) === 'dark' ? 'light' : 'dark')
)
// Usage
function CartSummary() {
const count = useAtomValue(cartCountAtom) // Only re-renders when count changes
const total = useAtomValue(cartTotalAtom)
return <div>{count} items · ${total.toFixed(2)}</div>
}jotai/utils package provides atomWithStorage, atomWithReset, atomWithObservable for common patterns.Philosophy: Transparent reactive state management. MobX makes state reactive through observables, computed values, and actions. It integrates naturally with OOP patterns and class-based stores.
MobX was transformative when React was younger and its fine-grained reactivity predated React's hooks model. In 2026, its star has faded in new projects, largely because Jotai offers similar fine-grained reactivity with less overhead and better React integration. MobX remains very capable and is still a good choice for large OOP-centric codebases with existing MobX investment.
Philosophy: Make state mutations feel natural. Valtio uses JavaScript Proxy to track state changes and automatically notify subscribed components. You mutate state directly and Valtio handles the rest.
Valtio's DX is extremely ergonomic for teams comfortable with direct mutations. The Proxy model means you don't need to think about immutability : Valtio handles snapshot diffing. However, its SSR story requires care, and for large teams, the lack of enforced patterns can lead to unpredictable state mutations. Best used in self-contained feature modules or rapid prototyping.
Philosophy: Tiny, framework-agnostic reactive atoms. Nanostores was designed for Astro and multi-framework islands architecture. It is the only state manager that works identically in React, Vue, Svelte, and vanilla JS.
At 265 bytes, Nanostores is the smallest possible option. It is ideal for Astro projects with multiple frameworks, or micro-frontend architectures where state must be shared across framework boundaries. For pure React apps, Zustand or Jotai are almost always preferable due to their deeper React integration and richer ecosystems.
Philosophy:Bypass React's reconciler for maximum performance. Signals are reactive primitives whose reads and writes are tracked automatically, similar to Vue 3's reactivity system. When a signal's value changes, only the DOM nodes that read it update: no component re-render.
The TC39 Signals proposal (Stage 1 as of 2026) aims to standardise this primitive at the JavaScript language level. Preact's signals implementation can be used in React via @preact/signals-react. The performance characteristics are exceptional for high-frequency updates (60fps animations, live data feeds), but the integration with React's rendering model requires understanding and care.
Philosophy: The fastest React state manager, designed for apps that need offline-first capabilities and realtime synchronisation built in.
Legend State uses an observable model similar to MobX but is built from the ground up for React. It includes first-class persistence (localStorage, IndexedDB, custom adapters), and sync plugins for Supabase, Firebase, and custom backends. Its observer() HOC and useSelector() hooks provide granular subscriptions. For apps where offline-first is a product requirement, Legend State is the strongest solution in the React ecosystem in 2026.
Server State Is Not Client State
This distinction is the most important insight in modern React architecture. Server state: data that lives on a remote server and is fetched into the browser: is fundamentally different from client state:
| Characteristic | Client State | Server State |
|---|---|---|
| Source of truth | Browser memory | Remote server / database |
| Persistence | Session only (unless stored) | Persisted on server |
| Staleness | Never stale (you own it) | Always potentially stale |
| Shared | Across components in-app | Across all clients / sessions |
| Needs | Reactivity, actions | Caching, invalidation, refetching |
General-purpose state managers (Zustand, Redux) are not designed to handle server state's lifecycle. They don't know about loading states, background refetching, stale-while-revalidate, cache invalidation, or pagination. You end up reimplementing all of this manually, poorly.
TanStack Query: The Dominant Solution
TanStack Query (formerly React Query) is the clear leader for client-side server state management in 2026, with over 45M weekly npm downloads. It provides a complete async state machine for every query and mutation: loading, error, success, stale, refetching, and background updating states are all handled automatically.
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'
// ─── Basic Query ───────────────────────────────────────────────────────────
function useUsers() {
return useQuery({
queryKey: ['users'], // Cache key: unique identifier
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes after unmount
})
}
// ─── Mutation with optimistic update ──────────────────────────────────────
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (user: User) => fetch(`/api/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify(user),
}).then(r => r.json()),
onMutate: async (updatedUser) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users'] })
// Snapshot current cache
const previous = queryClient.getQueryData<User[]>(['users'])
// Optimistic update
queryClient.setQueryData<User[]>(['users'], (old = []) =>
old.map(u => u.id === updatedUser.id ? { ...u, ...updatedUser } : u)
)
return { previous }
},
onError: (_err, _vars, context) => {
// Roll back on error
queryClient.setQueryData(['users'], context?.previous)
},
onSettled: () => {
// Always refetch after mutation
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// ─── Usage ─────────────────────────────────────────────────────────────────
function UserList() {
const { data: users, isLoading, isError, isFetching } = useUsers()
const updateUser = useUpdateUser()
if (isLoading) return <Spinner />
if (isError) return <ErrorBanner />
return (
<div>
{isFetching && <RefetchIndicator />}
{users?.map(user => (
<UserCard
key={user.id}
user={user}
onUpdate={(data) => updateUser.mutate({ id: user.id, ...data })}
isUpdating={updateUser.isPending}
/>
))}
</div>
)
}SWR: When to Prefer It
SWR (stale-while-revalidate), created by Vercel, remains a strong choice for simpler read-focused applications. Its API is more minimal than TanStack Query: one primary hook, straightforward configuration. If your app is primarily read-only with simple fetching, Next.js-first, and you don't need complex mutation patterns, SWR is an excellent, lighter option. Its useSWRInfinite handles infinite scroll reasonably well.
However, for complex mutation workflows, fine-grained cache invalidation, optimistic updates, and background refetching with full control, TanStack Query's API surface is more powerful and better documented. In 2026, TanStack Query has a larger community and faster release cadence.
RTK Query: When It Makes Sense
RTK Query is bundled inside Redux Toolkit and provides API slice-based data fetching fully integrated with the Redux store. If your team has already committed to Redux Toolkit, RTK Query is the natural choice: it avoids adding another dependency and provides tight integration with Redux DevTools. The trade-off is that it is more verbose than TanStack Query and less ergonomic for complex mutation patterns. For teams not using Redux, TanStack Query is almost always the better standalone choice.
SET_USERS_LOADING, SET_USERS_ERROR, SET_USERS_DATA actions, you are reinventing TanStack Query poorly. Use the right tool.XState and Finite State Machines
Some UI flows are fundamentally sequential and constrained: checkout, onboarding, booking, authentication. They have a defined set of states, legal transitions between states, and events that trigger those transitions. Boolean flags and ad-hoc state objects are notoriously poor tools for modelling these flows. They allow impossible states (e.g., isLoading: true AND isError: true simultaneously).
Finite State Machines (FSMs) eliminate impossible states by definition. A state machine can only be in one state at a time, and only valid transitions are allowed. XState is the leading FSM library for JavaScript and TypeScript in 2026, with v5 offering a significantly improved API with better TypeScript inference.
import { createMachine, assign } from 'xstate'
type CheckoutContext = {
orderId: string | null
error: string | null
paymentMethod: 'card' | 'paypal' | null
}
type CheckoutEvent =
| { type: 'SELECT_PAYMENT'; method: 'card' | 'paypal' }
| { type: 'SUBMIT' }
| { type: 'SUCCESS'; orderId: string }
| { type: 'FAILURE'; error: string }
| { type: 'RETRY' }
| { type: 'BACK' }
export const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
types: {} as { context: CheckoutContext; events: CheckoutEvent },
context: { orderId: null, error: null, paymentMethod: null },
states: {
cart: {
on: { SUBMIT: 'payment' }
},
payment: {
on: {
SELECT_PAYMENT: {
actions: assign({ paymentMethod: ({ event }) => event.method })
},
SUBMIT: {
guard: ({ context }) => context.paymentMethod !== null,
target: 'processing'
},
BACK: 'cart',
}
},
processing: {
on: {
SUCCESS: {
target: 'confirmed',
actions: assign({ orderId: ({ event }) => event.orderId })
},
FAILURE: {
target: 'payment',
actions: assign({ error: ({ event }) => event.error })
}
}
},
confirmed: { type: 'final' }
}
})
// Usage in a React component
import { useMachine } from '@xstate/react'
function CheckoutFlow() {
const [state, send] = useMachine(checkoutMachine)
return (
<div>
{state.matches('cart') && <CartStep onNext={() => send({ type: 'SUBMIT' })} />}
{state.matches('payment') && (
<PaymentStep
onSelect={(method) => send({ type: 'SELECT_PAYMENT', method })}
onSubmit={() => send({ type: 'SUBMIT' })}
onBack={() => send({ type: 'BACK' })}
/>
)}
{state.matches('processing') && <ProcessingStep />}
{state.matches('confirmed') && <ConfirmedStep orderId={state.context.orderId} />}
</div>
)
}When XState Is Worth the Complexity
Use XState When
- Multiple sequential steps with back/forward
- Async operations with loading, error, retry states
- Parallel state regions (e.g., form + autosave + validation)
- Complex onboarding, booking, or payment flows
- You need to visualise and communicate the flow
- Preventing impossible states is a hard requirement
Skip XState When
- Simple toggle or open/closed state
- Your team is unfamiliar with FSM concepts
- The flow has only 2–3 states
- A simple useReducer covers the requirements
- The added learning curve outweighs the benefit
How Modern Apps Combine These Tools
The key principle is single source of truth per state type. Server data lives in TanStack Query's cache. Global UI state lives in Zustand (or RTK). Local component state lives in useState. These domains don't overlap. Duplication between layers is the primary cause of sync bugs.
Pattern 1: Zustand + TanStack Query
The most popular modern stack. Zustand owns client-side state (authentication session, UI preferences, feature toggles), TanStack Query owns all server data. There is no duplication between them.
Architecture: Zustand + TanStack Query
Pattern 2: Redux Toolkit + RTK Query
For teams already using Redux. RTK Query API slices handle server data, RTK slices handle global UI state. Everything flows through the Redux store and DevTools, providing complete observability.
Architecture: Redux Toolkit + RTK Query
Pattern 3: Jotai + TanStack Query
Excellent for apps with complex derived state needs. Jotai atoms manage fine-grained client state. TanStack Query handles server data. You can even create Jotai atoms derived from TanStack Query data using atomWithQuery from jotai-tanstack-query.
select in useQuery, a Jotai derived atom, or a Redux selector: not a second store.Recommendations by Application Type
Small App / Portfolio / Internal Tool
1–3 devsNo global state library needed. Reach for one only if you hit clear prop drilling issues. TanStack Query for any API calls is worth it even at this scale.
Startup SaaS Product
2–8 devsZustand is fast to adopt and doesn't slow you down. TanStack Query eliminates custom data-fetching code. You can ship features fast without sacrificing architecture quality.
Mid-Sized Product
10–30 devsSplit Zustand stores by domain (useAuthStore, useCartStore). Introduce XState only where multi-step workflow logic gets complex. Document state ownership explicitly.
Enterprise System
30+ devs · ComplianceRTK's structured action logs provide audit trails. Time-travel debugging aids QA. Strict team conventions enforced by RTK's patterns prevent divergence across many engineers.
Realtime Collaborative App
MultiplayerLegend State's sync infrastructure is purpose-built for this. CRDT libraries (Yjs) handle conflict resolution. TanStack Query for initial data load and non-realtime endpoints.
Design / Canvas / Editor App
High client stateCanvas editors have rich client-side state. Immer handles complex nested mutations ergonomically. XState models undo/redo, selection, tool modes.
Next.js / RSC-First App
Server-firstPush as much state to the server as possible. Client state shrinks dramatically. TanStack Query for client-driven fetches. Minimal or no global state library.
Offline-First App
PWA / MobileLegend State's built-in persistence and sync is the best-in-class solution for offline-first React in 2026. TanStack Query alone is insufficient for offline scenarios that need optimistic persistence.
How RSC Changes State Management
React Server Components are the most significant architectural shift in React since hooks. When a component renders on the server, it can fetch its own data directly: no useEffect, no loading state, no client-side fetch. The component becomes a pure function of server-side data.
// app/users/page.tsx: React Server Component
// This file runs on the SERVER. No useState, no useEffect, no loading state.
async function getUsers(): Promise<User[]> {
const response = await fetch('https://api.example.com/users', {
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
})
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
}
export default async function UsersPage() {
const users = await getUsers() // Direct await: no hooks needed
return (
<main>
<h1>Users</h1>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</main>
)
}// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.users.create({ data: { name, email } })
revalidatePath('/users') // Invalidate the cached page
}
// app/users/new/page.tsx
import { createUser } from '../actions'
export default function NewUserPage() {
return (
<form action={createUser}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Create User</button>
</form>
)
// No useState, no useEffect, no API client, no loading state management
}Client / Server Boundary
The 'use client' directive marks the boundary where a component tree becomes interactive. Everything above the boundary is a Server Component. State management libraries (Zustand, TanStack Query) only make sense below this boundary: in the client component tree.
In a well-architected Next.js 15 app, the client boundary should be pushed as low as possible. A page may be 80% Server Components (fetching, rendering static content) with small "islands" of interactivity that use client state.
TanStack Query with RSC: Prefetching Pattern
// app/dashboard/page.tsx: Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { DashboardClient } from './DashboardClient'
export default async function DashboardPage() {
const queryClient = new QueryClient()
// Prefetch on server: data is serialised into HTML
await queryClient.prefetchQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
})
return (
// Dehydrated state is passed to client: no waterfall
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient />
</HydrationBoundary>
)
}
// app/dashboard/DashboardClient.tsx: Client Component
'use client'
import { useQuery } from '@tanstack/react-query'
export function DashboardClient() {
// This query is already cached: no loading state on first render
const { data } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
})
return <DashboardStats data={data} />
}This pattern eliminates client-side loading spinners for initial page load. The server prefetches and serialises data, the client hydrates with it instantly, and subsequent client-side navigations use TanStack Query's cache normally.
Rerenders, Selectors, and Granular Subscriptions
The Rerender Problem
Every state update triggers a rerender of the component that owns the state, plus all its children (unless memoised). The performance question is: how precisely can a library scope a state update to only the components that care?
| Library | Subscription Model | Rerender Scope | Optimisation Mechanism |
|---|---|---|---|
| Context API | Value reference equality | All consumers on any change | useMemo on value, context splitting |
| Zustand | Selector equality check | Only components with changed selector result | Selector functions, shallow comparison |
| Redux Toolkit | Selector equality check | Only components with changed selector result | Reselect memoised selectors |
| Jotai | Per-atom subscription | Only atoms that changed | Atomic model: naturally granular |
| MobX | Observable dependency tracking | Only components reading changed observables | Automatic dependency tracking |
| Signals | Fine-grained reactive | Individual DOM nodes, not component tree | Bypasses React reconciler entirely |
| TanStack Query | Per-query subscription | Only consumers of changed query | Query key isolation, staleTime |
Selectors in Zustand
const useAppStore = create<AppStore>()(/* ... */)
// ✗ Bad: re-renders whenever ANY part of the store changes
function Sidebar() {
const store = useAppStore()
return <div>{store.sidebarOpen ? 'Open' : 'Closed'}</div>
}
// ✓ Good: only re-renders when sidebarOpen changes
function Sidebar() {
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
return <div>{sidebarOpen ? 'Open' : 'Closed'}</div>
}
// ✓ Good: shallow equality for objects
import { useShallow } from 'zustand/react/shallow'
function UserBadge() {
const { name, role } = useAppStore(
useShallow((s) => ({ name: s.user?.name, role: s.user?.role }))
)
return <span>{name} · {role}</span>
}Context API Rerender Issues
Context's biggest performance problem is that all consumers re-render when the context value reference changes: regardless of whether the specific values they consume changed. The solutions:
- Memoize the context value with
useMemo - Split contexts by update frequency (ThemeContext vs UserContext)
- Use
use-context-selectorlibrary for selector-based subscriptions - Or just switch to Zustand: it was designed to solve this problem
Normalised State
In Redux, normalising state (storing entities by ID in a flat object, not nested arrays) is a critical performance technique. RTK's createEntityAdapter provides this out of the box. In TanStack Query, normalisation is less critical since each query key is its own cache entry.
Stale Closures
Stale closures are a common bug with useEffectand event handlers that capture state values at creation time. Zustand's getState()method allows reading current state inside callbacks without capturing stale values. TanStack Query's queryClient.getQueryData() serves the same purpose for server data.
TypeScript DX Across State Libraries
| Library | Inference | Action Typing | Selector Typing | Async Typing | Boilerplate | Overall DX |
|---|---|---|---|---|---|---|
| useState | Excellent (generic) | N/A | N/A | N/A | None | Excellent |
| useReducer | Excellent (discriminated unions) | Excellent (union types) | N/A | N/A | Low | Excellent |
| Zustand | Excellent (inferred from store def) | Excellent (typed functions) | Excellent (inferred return) | Good | Very Low | Excellent |
| Redux Toolkit | Very Good (some boilerplate) | Very Good (PayloadAction) | Very Good (TypedUseSelectorHook) | Good (createAsyncThunk) | Medium | Very Good |
| Jotai | Excellent (atom generic) | Excellent | Excellent (derived atoms) | Excellent (atomWithQuery) | None | Excellent |
| MobX | Good (with decorators) | Good | Good | Good | Medium | Good |
| XState | Excellent (v5 greatly improved) | Excellent (typed events) | Excellent (typed states) | Excellent | High | Very Good |
| TanStack Query | Excellent (generic queryFn) | N/A | Excellent (select option) | Excellent (Promise-based) | Very Low | Excellent |
Typed Redux Store Pattern
// store/hooks.ts: Typed hooks for the entire codebase
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// Usage: fully typed, no any
const user = useAppSelector((s) => s.auth.user) // User | null: inferred
const dispatch = useAppDispatch()
dispatch(addItem({ id: '1', name: 'Shirt', price: 29.99 })) // Typed payloadCommon State Management Mistakes
1. Storing Server State in a Global State Library
The most common and costly mistake. Putting API response data directly into Redux or Zustand means you now own the caching, invalidation, loading, error, and refetching lifecycle: poorly, with significant boilerplate. Use TanStack Query instead.
// ✗ Anti-pattern: manual server state in Zustand
const useUsersStore = create((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true })
try {
const users = await api.getUsers()
set({ users, isLoading: false })
} catch (e) {
set({ error: e.message, isLoading: false })
}
}
}))
// ✓ Correct: TanStack Query owns server state
function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: api.getUsers })
}2. Over-Globalising State
Not every piece of state needs to be global. If only one component (or a tightly coupled subtree) needs a value, keep it local. Global state is a shared resource: it creates implicit coupling between components that can make refactoring painful.
3. Prop Drilling When a Simpler Lift Exists
Before adding a global state library, ask: can I lift state one or two levels up and pass it via props? Sometimes prop drilling is the right choice: explicit data flow is easier to understand than implicit global state access. The problem threshold is typically 3+ levels of drilling.
4. Duplicated State Between Cache and Store
Copying TanStack Query cache results into Zustand creates two sources of truth that will inevitably diverge. Instead, derive from the cache: useQuery() directly in the component, or use TanStack Query's select option to transform data.
5. Giant Monolithic Stores
A single Zustand store with 30 fields and 40 actions is hard to reason about, hard to test, and creates unnecessary coupling. Split stores by domain: useAuthStore, useCartStore, useUIStore. Each slice should have a single clear responsibility.
6. Unnecessary Reducers for Simple State
Using useReducer for a single boolean toggle is over-engineering.useState(false) is perfectly correct. Reach for useReducer when you have multiple related state values that change together, or when state transitions have meaningful names worth representing as actions.
7. Context for High-Churn State
Putting a search input value, mouse position, or animation frame state in Context will cause every context consumer to re-render at potentially 60fps. High-frequency state belongs in local useState or in a library with proper subscription isolation (Zustand, Jotai, Signals).
8. Storing Derived State
Storing state that can be computed from other state (cart total from cart items, filtered list from items + filter) creates a sync bug waiting to happen. Compute it with useMemo, a Jotai derived atom, or a Redux selector. Never store it separately.
What's Growing, Stable, and Declining
Growing
- TanStack Query: Dominant for server state. Now v5 with improved SSR and RSC patterns. 45M+ weekly downloads and growing.
- Zustand: The modern default for global client state in new projects. Fast, minimal, and excellent TypeScript support.
- Jotai: Gaining ground as the atomic state solution, particularly for complex derived state needs and as a Recoil replacement.
- Legend State: Rapidly growing for offline-first and realtime-sync use cases. The best-in-class solution for those requirements.
- Signals: Interest growing with the TC39 proposal progression. Preact Signals in React gaining adoption for performance-critical UIs.
- Server Components / Server Actions: Reducing the need for client-side state management entirely in server-first architectures.
Stable
- Redux Toolkit: Stable at enterprise scale. No longer growing in new project adoption but entrenched in large organisations. Will remain relevant for years due to existing investment.
- XState: v5 improved the API significantly. Stable niche for complex workflow modelling. Not mainstream but deeply valued where it fits.
- SWR: Stable but losing ground to TanStack Query. Remains excellent for Vercel-first, simple fetching use cases.
- Context API: Remains useful for low-frequency global state. The React team has experimented with new primitives (use()) that may eventually make some Context uses redundant.
Declining
- Recoil: Effectively unmaintained by Meta. Community has moved to Jotai. Do not use in new projects.
- MobX: Still capable but declining in new project adoption. Jotai is preferred for similar use cases.
- Classic Redux (without RTK): Legacy pattern. RTK is the only way to write Redux in 2026.
- Manual data fetching in useEffect: Replaced by TanStack Query or Server Components. Still common in legacy codebases.
Emerging
AI-assisted state design: LLM tooling is beginning to suggest appropriate state architecture based on component trees and API shapes. Expect this to accelerate in 2026–2027.
Type-safe API layers: tRPC and similar tools that generate fully-typed server-state fetching are reducing the gap between server and client type systems, making state management code cleaner and less error-prone.
React Compiler (React Forget): The opt-in React Compiler (stabilising in 2025–2026) automatically inserts useMemo and useCallback at the right places, reducing the performance discipline required for manual memoisation. This may change selector and subscription patterns in libraries.
Full Library Comparison Matrix
Scroll horizontally to see all columns. Ratings reflect 2026 ecosystem status based on npm trends, GitHub activity, community sentiment, and production usage patterns.
| Library | State Type | Best For | Scale | Boilerplate | Learning | Performance | TypeScript | SSR | RSC | Bundle | Ecosystem | Enterprise | Trend 2026 | Recommended For | Avoid When |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| useState | Local UI | Component-scoped state | Any | Excellent | None (built-in) | Excellent | Excellent | Built-in | Good | 0 KB | Excellent | Excellent | Stable | Any component-level UI state | State must be shared across many distant components |
| useReducer | Local complex | Multi-action component state | Any | Excellent | Low | Excellent | Excellent | Built-in | Good | 0 KB | Excellent | Excellent | Stable | Complex local state, reducers with multiple actions | Simple toggle / counter state (overkill) |
| Context API | Global (limited) | Theme, auth, locale | Small–Med | Good | Low | Fair | Good | Built-in | Good | 0 KB | Excellent | Good | Stable | Infrequent global state: theme, auth session, i18n | High-churn state; frequently updated values |
| Zustand | Global client | Lightweight global state | All sizes | Excellent | Very Low | Excellent | Excellent | Good | Good | ~1 KB | Very Good | Good | Growing | Most global client state; replacing Redux in modern apps | Server/async data (use TanStack Query instead) |
| Redux Toolkit | Global client | Complex auditable state | Med–Enterprise | Good | Medium | Very Good | Very Good | Very Good | Good | ~12 KB | Excellent | Excellent | Stable | Enterprise teams, time-travel debugging, strict standards | Small apps; server data only (use TanStack Query) |
| Jotai | Global (atomic) | Fine-grained reactive state | All sizes | Excellent | Low | Excellent | Excellent | Good | Good | ~3 KB | Very Good | Good | Growing | Per-atom subscriptions, derived state, Recoil migrations | Very simple global state (Zustand may be simpler) |
| MobX | Global (reactive) | OOP-style reactive state | Med–Large | Good | Medium | Excellent | Good | Good | Fair | ~16 KB | Very Good | Good | Declining | OOP-oriented teams; legacy MobX codebases | New greenfield projects; RSC-heavy architectures |
| Valtio | Global (proxy) | Mutable-feel global state | Small–Med | Excellent | Very Low | Very Good | Good | Fair | Fair | ~2.8 KB | Good | Fair | Niche | Prototypes; teams comfortable with proxy mutations | SSR-heavy apps; large teams needing strict patterns |
| TanStack Query | Server/async | All server data fetching | All sizes | Good | Medium | Excellent | Excellent | Excellent | Very Good | ~12 KB | Excellent | Excellent | Dominant | Any app fetching server/API data | Pure RSC apps; trivial one-off fetches with no caching needs |
| SWR | Server/async | Simple read-heavy fetching | Small–Med | Excellent | Very Low | Very Good | Good | Very Good | Very Good | ~4 KB | Very Good | Good | Stable | Vercel/Next.js apps; simple read-focused data requirements | Complex mutations, optimistic updates, advanced pagination |
| RTK Query | Server/async | API fetching in Redux apps | Med–Large | Good | Medium–High | Very Good | Very Good | Good | Fair | Included in RTK | Very Good | Very Good | Stable | Apps already committed to Redux Toolkit | Not using Redux; prefer TanStack Query for standalone use |
| XState | Workflow / FSM | Complex workflow logic | Med–Large | High | High | Excellent | Excellent | Good | Good | ~8 KB (v5) | Good | Good | Stable | Payment flows, booking, multi-step onboarding, async orchestration | Simple toggle/modal state; teams unfamiliar with FSM concepts |
| Signals | Global / reactive | Fine-grained reactive UI | All sizes | Excellent | Low | Excellent | Good | Good | Fair | ~1 KB | Good | Early adoption | Growing | Performance-critical UIs; Preact Signals in React; tc39 experiments | Pure React apps (not yet native to React without adapters) |
| Legend State | Global / reactive | Offline-first, realtime, high-perf | Med–Large | Good | Medium | Excellent | Excellent | Good | Good | ~9 KB | Growing | Growing | Growing | Offline-first apps, realtime sync, performance-critical dashboards | Simple apps not needing offline/sync; teams unfamiliar with observables |
| Recoil | Global (atomic) | Legacy Facebook-scale apps | Med | Good | Medium | Good | Good | Fair | Poor | ~20 KB | Declining | Legacy only | Declining | Legacy projects already using Recoil | New projects: use Jotai instead; RSC-heavy architectures |
If Your Situation Is X, Use Y
Use this framework as a starting point, not a rigid rule. Every team and codebase has unique constraints: existing investment, team familiarity, and integration requirements always factor in.
Small / Personal App
Building a small portfolio, blog, or internal tool
Recommended Stack
No global state library needed. Context for theme. TanStack Query for any server data.
Startup SaaS
Moving fast, 2–8 engineers, REST/GraphQL API
Recommended Stack
Zustand for UI state, TanStack Query for server data. Ship fast, stay lean.
Mid-Sized Product
10–30 engineers, complex UI state + API-heavy
Recommended Stack
Introduce XState only for complex multi-step workflows. Keep stores domain-separated.
Enterprise System
Large team, audit trail, strict state patterns needed
Recommended Stack
Redux Toolkit provides time-travel debugging and strict action patterns essential for compliance-heavy contexts.
Realtime Collaborative
Live cursors, shared documents, concurrent edits
Recommended Stack
Legend State's observable model pairs well with CRDT libraries. TanStack Query for initial data load.
Design / Editor App
Canvas, drag-and-drop, undo/redo, complex local state
Recommended Stack
Rich client-side state. Immer for immutable updates. XState for undo/redo state machine.
React Native App
Mobile app with offline support
Recommended Stack
Legend State for offline-first persistence. TanStack Query for API. Zustand for UI state.
Offline-First App
Must work without connectivity, sync on reconnect
Recommended Stack
Legend State's persistence and sync model is the best fit for offline-first React apps in 2026.
Next.js / RSC App
Server-first, data mostly fetched on server
Recommended Stack
Reduce client-side state aggressively. Use Server Components and Server Actions for most data. Client state only for UI interactions.
Performance-Critical UI
High-frequency updates, large lists, 60fps requirements
Recommended Stack
Atomic state minimises rerender scope. Signals bypass React reconciler for hot paths.
Form-Heavy App
Multi-step forms, complex validation, field arrays
Recommended Stack
React Hook Form is the clear winner for form state. Don't put form data in Redux or Zustand.
High-Compliance System
HIPAA, SOC2, financial: full audit trail required
Recommended Stack
Redux's deterministic, serializable action log provides the audit trail that compliance requires.
Nuanced Conclusions for 2026
Best Overall Modern Stack
Zustand + TanStack Query + React Hook Form
This is the pragmatic default for new React projects in 2026. Zustand handles global UI state with minimal ceremony. TanStack Query handles all server data. React Hook Form handles forms. Each is the best-in-class tool for its domain, and they compose cleanly without overlap. Bundle footprint is small, TypeScript DX is excellent, and the learning curve is low enough that a junior developer can be productive quickly.
Best Enterprise Stack
Redux Toolkit + RTK Query + XState (for critical flows)
For large organisations where auditability, strict patterns, and DevTools visibility are non-negotiable. RTK's enforced structure prevents state management divergence across many developers. The action log provides the audit trail compliance-heavy industries require.
Best Startup Stack
Zustand + TanStack Query + Server Components (Next.js)
Move fast, ship features, keep the bundle lean. Server Components handle as much data fetching as possible. TanStack Query handles the rest on the client. Zustand for the small amount of genuine global UI state. Zero state management overhead for data that doesn't need to be in the browser at all.
Best Performance-Oriented Stack
Jotai + Signals + TanStack Query
Jotai's atomic model provides the most granular subscriptions in the React world: only the exact atom that changed triggers a rerender. Signals bypass the React reconciler for the hottest paths. TanStack Query's intelligent caching minimises network requests.
Best Beginner-Friendly Stack
useState + TanStack Query + Context (for globals)
Start with React's built-in primitives. Add TanStack Query early: it teaches good patterns around server state and is easy to learn. Add Zustand when prop drilling genuinely becomes a problem (not before). Avoid introducing Redux or XState until the team has a solid React foundation.
The Architecture Principles That Don't Change
- Single source of truth per state type. Never duplicate server data into a client store.
- Colocate state with its consumers. Global by default is an anti-pattern. Global by necessity is the goal.
- Prefer server-side by default in RSC apps. The less state you manage in the browser, the simpler your architecture.
- Reach for complexity only when justified. XState before useReducer, Redux before Zustand: only when the use case genuinely demands the added structure.
- Measure before optimising. Premature performance optimisation of state (selectors, atomic splitting) adds complexity. Profile first.
React State Management in 2026 · Written by Mahsa Mohajer · Updated May 2026