Skip to main content
Engineering Reference · 2026

React State Management in 2026

A complete architecture guide covering every major state management solution, modern patterns, performance, TypeScript DX, and real-world recommendations for all team sizes.

~45 min readBeginner → AdvancedReact 19 · TypeScript · Next.js 15
01 / Introduction

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.

The core insight of modern React state management: There is no single library that should manage all your state. Server state, global UI state, local component state, form state, and workflow state each have different characteristics and need different solutions.

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?"

02 / Types of State

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 / useReducer
🌐

Global 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 / Jotai
🖥️

Server 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 / RSC
🔗

URL / 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 / useSearchParams
📝

Form 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 / Formik
🔄

Workflow / 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 / useReducer
🧮

Derived 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 / selectors

Realtime 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 / PartyKit
The most common architecture mistake: Using a global state library (Redux, Zustand) to store server state. Server data has its own lifecycle (caching, refetching, stale-while-revalidate) that general-purpose stores handle poorly. Use TanStack Query or SWR for server state, and reserve your global store for genuinely client-owned state.
03 / Native React State

useState, 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.

tsxuseState with TypeScript
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.

tsxuseReducer with discriminated union actions
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.

tsxContext done right: memoize the value
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.

04 / Global Client State

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.

Zustand
Growing~1 KBMinimal boilerplate

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.

tszustand store
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
Best fit: Most modern SaaS products, startup teams, mid-sized apps. Pair with TanStack Query for server data. Split stores by domain (useAuthStore, useCartStore, useUIStore) to keep boundaries clear.
Redux Toolkit
Stable~12 KBEnterprise

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.

tsRedux Toolkit slice
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.actions
Best fit: Enterprise, large teams, compliance-heavy systems (fintech, healthcare), apps where time-travel debugging and full action logs are non-negotiable. In 2026, new SaaS products should prefer Zustand + TanStack Query unless RTK is already an organisational standard.
Jotai
Growing~3 KBAtomic

Philosophy: 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.

tsJotai atoms and derived state
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>
}
Best fit: Apps with many independent state pieces, complex derived/computed state, fine-grained subscription requirements. Excellent for teams migrating away from Recoil. The jotai/utils package provides atomWithStorage, atomWithReset, atomWithObservable for common patterns.
MobX
Declining~16 KBReactive OOP

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.

2026 recommendation: Prefer Jotai or Zustand for new projects. Maintain existing MobX codebases rather than rewriting unless there is a compelling reason. RSC compatibility is limited.
Valtio
Niche~2.8 KBProxy-based

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.

Nanostores
Niche~265 BFramework-agnostic

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.

Signals (Preact / @preact/signals-react)
Growing~1 KBReactive primitive

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.

Watch closely: If TC39 Signals reaches Stage 3+, they could become the foundation for next-generation state management across all JavaScript frameworks. React itself may evolve its primitive model accordingly.
Legend State
Growing~9 KBOffline-first

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.

Recoil
Declining~20 KBAvoid for new projects
Deprecation status: Recoil was created by Meta (Facebook) and saw minimal maintenance after 2022. The last major release (0.7.x) is from 2022. Meta has effectively shifted its internal focus away from Recoil. The community has largely moved to Jotai, which offers a similar atomic model with active maintenance, better SSR support, and a much smaller bundle. Do not use Recoil for new projects. If you have an existing Recoil codebase, plan a Jotai migration.
05 / Server State Management

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:

CharacteristicClient StateServer State
Source of truthBrowser memoryRemote server / database
PersistenceSession only (unless stored)Persisted on server
StalenessNever stale (you own it)Always potentially stale
SharedAcross components in-appAcross all clients / sessions
NeedsReactivity, actionsCaching, 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.

tsxTanStack Query: complete example
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.

Key rule: Never store server data in Zustand or Redux as your primary solution. If you find yourself writing SET_USERS_LOADING, SET_USERS_ERROR, SET_USERS_DATA actions, you are reinventing TanStack Query poorly. Use the right tool.
06 / Workflow & State Machines

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.

tsXState v5: checkout flow machine
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
07 / Modern Architecture

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

ServerTanStack Query CacheuseQuery / useMutationComponents
ClientZustand StoreuseAppStore(selector)Components
LocaluseState / useReducerComponent scope

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

APIRTK Query API SliceuseGetUsersQuery()
UI StateRTK createSliceuseSelector / dispatch
BothRedux Store + DevTools

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.

The golden rule: TanStack Query cache is the source of truth for server data: do not copy query results into Zustand or Redux. If you need to derive something from server data, use select in useQuery, a Jotai derived atom, or a Redux selector: not a second store.
08 / State by App Scale

Recommendations by Application Type

Small App / Portfolio / Internal Tool

1–3 devs
useStateContext (sparingly)TanStack Query

No 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 devs
ZustandTanStack QueryReact Hook Form

Zustand 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 devs
Zustand (domain-split)TanStack QueryXState (for flows)

Split Zustand stores by domain (useAuthStore, useCartStore). Introduce XState only where multi-step workflow logic gets complex. Document state ownership explicitly.

Enterprise System

30+ devs · Compliance
Redux ToolkitRTK QueryXState

RTK'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

Multiplayer
Legend StateYjs / PartyKitTanStack Query

Legend 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 state
Zustand + ImmerXState (undo machine)TanStack Query (save/load)

Canvas 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-first
Server Components (data)Server Actions (mutations)useState (minimal UI)

Push 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 / Mobile
Legend StateIndexedDB adapterService Worker

Legend 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.

09 / Next.js & React Server Components

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.

The RSC insight:If your data is fetched once and displayed (no client interaction needed), it probably shouldn't be in client-side state at all. Move it to a Server Component. Eliminate the problem instead of solving it.
tsxServer Component: no client state needed
// 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>
  )
}
tsxServer Actions: mutations without useState
// 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

tsxTanStack Query + RSC prefetching
// 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.

10 / Performance Deep Dive

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?

LibrarySubscription ModelRerender ScopeOptimisation Mechanism
Context APIValue reference equalityAll consumers on any changeuseMemo on value, context splitting
ZustandSelector equality checkOnly components with changed selector resultSelector functions, shallow comparison
Redux ToolkitSelector equality checkOnly components with changed selector resultReselect memoised selectors
JotaiPer-atom subscriptionOnly atoms that changedAtomic model: naturally granular
MobXObservable dependency trackingOnly components reading changed observablesAutomatic dependency tracking
SignalsFine-grained reactiveIndividual DOM nodes, not component treeBypasses React reconciler entirely
TanStack QueryPer-query subscriptionOnly consumers of changed queryQuery key isolation, staleTime

Selectors in Zustand

tsxGranular selectors prevent unnecessary rerenders
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-selector library 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.

11 / TypeScript Experience

TypeScript DX Across State Libraries

LibraryInferenceAction TypingSelector TypingAsync TypingBoilerplateOverall DX
useStateExcellent (generic)N/AN/AN/ANoneExcellent
useReducerExcellent (discriminated unions)Excellent (union types)N/AN/ALowExcellent
ZustandExcellent (inferred from store def)Excellent (typed functions)Excellent (inferred return)GoodVery LowExcellent
Redux ToolkitVery Good (some boilerplate)Very Good (PayloadAction)Very Good (TypedUseSelectorHook)Good (createAsyncThunk)MediumVery Good
JotaiExcellent (atom generic)ExcellentExcellent (derived atoms)Excellent (atomWithQuery)NoneExcellent
MobXGood (with decorators)GoodGoodGoodMediumGood
XStateExcellent (v5 greatly improved)Excellent (typed events)Excellent (typed states)ExcellentHighVery Good
TanStack QueryExcellent (generic queryFn)N/AExcellent (select option)Excellent (Promise-based)Very LowExcellent

Typed Redux Store Pattern

tsTyped Redux selectors and dispatch
// 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 payload
12 / Anti-Patterns

Common 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.

ts
// ✗ 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.

14 / Ultimate Comparison

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.

LibraryState TypeBest ForScaleBoilerplateLearningPerformanceTypeScriptSSRRSCBundleEcosystemEnterpriseTrend 2026Recommended ForAvoid When
useStateLocal UIComponent-scoped stateAnyExcellentNone (built-in)ExcellentExcellentBuilt-inGood0 KBExcellentExcellentStableAny component-level UI stateState must be shared across many distant components
useReducerLocal complexMulti-action component stateAnyExcellentLowExcellentExcellentBuilt-inGood0 KBExcellentExcellentStableComplex local state, reducers with multiple actionsSimple toggle / counter state (overkill)
Context APIGlobal (limited)Theme, auth, localeSmall–MedGoodLowFairGoodBuilt-inGood0 KBExcellentGoodStableInfrequent global state: theme, auth session, i18nHigh-churn state; frequently updated values
ZustandGlobal clientLightweight global stateAll sizesExcellentVery LowExcellentExcellentGoodGood~1 KBVery GoodGoodGrowingMost global client state; replacing Redux in modern appsServer/async data (use TanStack Query instead)
Redux ToolkitGlobal clientComplex auditable stateMed–EnterpriseGoodMediumVery GoodVery GoodVery GoodGood~12 KBExcellentExcellentStableEnterprise teams, time-travel debugging, strict standardsSmall apps; server data only (use TanStack Query)
JotaiGlobal (atomic)Fine-grained reactive stateAll sizesExcellentLowExcellentExcellentGoodGood~3 KBVery GoodGoodGrowingPer-atom subscriptions, derived state, Recoil migrationsVery simple global state (Zustand may be simpler)
MobXGlobal (reactive)OOP-style reactive stateMed–LargeGoodMediumExcellentGoodGoodFair~16 KBVery GoodGoodDecliningOOP-oriented teams; legacy MobX codebasesNew greenfield projects; RSC-heavy architectures
ValtioGlobal (proxy)Mutable-feel global stateSmall–MedExcellentVery LowVery GoodGoodFairFair~2.8 KBGoodFairNichePrototypes; teams comfortable with proxy mutationsSSR-heavy apps; large teams needing strict patterns
TanStack QueryServer/asyncAll server data fetchingAll sizesGoodMediumExcellentExcellentExcellentVery Good~12 KBExcellentExcellentDominantAny app fetching server/API dataPure RSC apps; trivial one-off fetches with no caching needs
SWRServer/asyncSimple read-heavy fetchingSmall–MedExcellentVery LowVery GoodGoodVery GoodVery Good~4 KBVery GoodGoodStableVercel/Next.js apps; simple read-focused data requirementsComplex mutations, optimistic updates, advanced pagination
RTK QueryServer/asyncAPI fetching in Redux appsMed–LargeGoodMedium–HighVery GoodVery GoodGoodFairIncluded in RTKVery GoodVery GoodStableApps already committed to Redux ToolkitNot using Redux; prefer TanStack Query for standalone use
XStateWorkflow / FSMComplex workflow logicMed–LargeHighHighExcellentExcellentGoodGood~8 KB (v5)GoodGoodStablePayment flows, booking, multi-step onboarding, async orchestrationSimple toggle/modal state; teams unfamiliar with FSM concepts
SignalsGlobal / reactiveFine-grained reactive UIAll sizesExcellentLowExcellentGoodGoodFair~1 KBGoodEarly adoptionGrowingPerformance-critical UIs; Preact Signals in React; tc39 experimentsPure React apps (not yet native to React without adapters)
Legend StateGlobal / reactiveOffline-first, realtime, high-perfMed–LargeGoodMediumExcellentExcellentGoodGood~9 KBGrowingGrowingGrowingOffline-first apps, realtime sync, performance-critical dashboardsSimple apps not needing offline/sync; teams unfamiliar with observables
RecoilGlobal (atomic)Legacy Facebook-scale appsMedGoodMediumGoodGoodFairPoor~20 KBDecliningLegacy onlyDecliningLegacy projects already using RecoilNew projects: use Jotai instead; RSC-heavy architectures
15 / Decision Framework

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

useStateuseReducerTanStack Query

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

ZustandTanStack QueryReact Hook Form

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

ZustandTanStack QueryXState (flows)

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 ToolkitRTK QueryXState

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 StateYjs / PartyKitTanStack Query

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

ZustandImmerXState (undo machine)

Rich client-side state. Immer for immutable updates. XState for undo/redo state machine.

React Native App

Mobile app with offline support

Recommended Stack

ZustandTanStack QueryLegend State

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 StateWatermelonDB / RxDBTanStack Query

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

useState (minimal)TanStack Query (client)Server Actions

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

JotaiSignalsTanStack Query

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 FormZustand (form state)TanStack Query

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 ToolkitRTK QueryXState

Redux's deterministic, serializable action log provides the audit trail that compliance requires.

16 / Final Recommendations

Nuanced Conclusions for 2026

The single most important recommendation: Always use TanStack Query (or SWR) for server state. This decision alone eliminates the majority of state management complexity in most apps.

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