Frontend Architecture

[Frontend Architecture] State Management - useState/Zustand/TanStack Query

[Frontend Architecture] State Management - useState/Zustand/TanStack Query

About this article

As the third installment of the “Frontend Architecture” category in the series “Architecture Crash Course for the Generative-AI Era,” this article explains state management.

This is the most difficult area to design in modern frontend development. This article covers the classification of state into 5 types (UI, domain, server, URL, persistent), modern standard stacks like Zustand, TanStack Query, and React Hook Form + Zod, design principles, and the iron rule of “never write the same fact in two places.”

What is state management in the first place

State management is, roughly speaking, “deciding where in the app to hold the data and status displayed on screen, and how to update them.”

Imagine sharing info on whiteboards. A small team (small app) can check info on one whiteboard (useState). But as departments grow, each needs its own board (Zustand), plus a system to fetch the latest from the company-wide bulletin (server state = TanStack Query). Without rules for what goes where, someone will make decisions based on stale info.

Why state management design matters

What happens if state management is left vague? Confusion like “where is this value managed?” and “I updated it but it’s not reflected” is almost always caused by failed state design. The bigger the app, the more brutally it shows in code quality — and once tangled, fixing it is harder than rewriting from scratch.

State has different optimal management methods per type. Lumping them together always breaks down.

”Where is this value managed?”

The bigger an app gets, the more brutally state-management quality shows up in code quality. Confusion like “where is this value managed?” or “I updated it but it’s not reflected” is almost always caused by a failed state design. Once tangled, fixing it later is harder than rewriting from scratch.

State has different optimal management methods for different types. Treating them all the same will always break down.

Types of state

The first key insight is that “state” is not one thing - 5 types with completely different properties exist. Trying to manage them all with the same mechanism (like Redux) is doomed to fail.

flowchart TB
    STATE([State])
    UI[UI state<br/>modal/input/loading]
    DOM[Domain state<br/>cart/favorites]
    SRV[Server state<br/>API-fetched data]
    URL[URL state<br/>query/pagination]
    PERS[Persistent state<br/>login/theme]
    UI_TOOL[useState<br/>useReducer]
    DOM_TOOL[Zustand<br/>Jotai]
    SRV_TOOL[TanStack Query<br/>SWR]
    URL_TOOL[Next.js Router<br/>nuqs]
    PERS_TOOL[Cookie<br/>localStorage]
    STATE --> UI --> UI_TOOL
    STATE --> DOM --> DOM_TOOL
    STATE --> SRV --> SRV_TOOL
    STATE --> URL --> URL_TOOL
    STATE --> PERS --> PERS_TOOL
    classDef root fill:#fef3c7,stroke:#d97706;
    classDef kind fill:#dbeafe,stroke:#2563eb;
    classDef tool fill:#dcfce7,stroke:#16a34a;
    class STATE root;
    class UI,DOM,SRV,URL,PERS kind;
    class UI_TOOL,DOM_TOOL,SRV_TOOL,URL_TOOL,PERS_TOOL tool;
TypeExamples
UI stateModal open/close, input values, loading indicators
Domain stateCart contents, favorites list
Server stateUser list, product details fetched from API
URL stateQuery parameters, pagination, search conditions
Persistent stateLogin info, theme settings, drafts

The classic failure is “mixing server state and UI state.” The two have fundamentally different properties - whether caching is needed and when they go stale. The first step in state design is identifying which type your data belongs to.

The modern consensus is to “manage server state with TanStack Query, UI state with useState/Zustand” - separately.

Local state and Lift State Up

The simplest and most-used kind is local state. If a value is used only inside one component, holding it with useState is the simplest path with the fewest issues.

const [count, setCount] = useState(0)
const [isOpen, setIsOpen] = useState(false)

The key principle is “lift it up only when multiple components need it.” There is no need to put state in a global store from day one. Abstracting before you need it leads to design failure.

When state needs to be shared across components, the basic approach is to lift it up to the common parent. This is called Lift State Up.

  [Parent]   ← useState lives here
   ├─ Child1 ← receives via props
   └─ Child2 ← receives via props (and the setter)

This is React’s textbook pattern - simple and explicit. But once the hierarchy gets deep, props pass through intermediate components like a bucket brigade - the infamous “Prop Drilling”. When you hit 4-5 layers, it’s time to consider Context or an external store.

Simple values are fine with useState. Premature abstraction is the biggest enemy.

Global state options

For state shared across the entire app (logged-in user info, theme, sidebar open/close), use a global store. There are several options - pick based on team preference and app size.

LibraryCharacteristics
Redux / Redux ToolkitVeteran, huge ecosystem, lots of boilerplate
ZustandLightweight, hook-driven, simple to write
JotaiAtom-oriented, supports React concurrent mode
RecoilFrom Facebook but maintenance has stalled - avoid
ValtioProxy-based, natural feel
MobXObservable-driven, Vue-like feel

For new projects, Zustand or Jotai are the front-runners. Redux is reserved for complex large-scale SPAs or cases where Redux DevTools’ debugging power is essential. Redux’s “action → reducer → store” diagram is conceptually elegant, but its verbosity tends to be disliked.

For new adoption, choose Zustand / Jotai. If you choose Redux, do so with a clear reason.

Caveats with React Context

React’s standard Context API can be used as a lightweight global store. However, there’s a major constraint: when even one value in Context changes, every subscribed component re-renders.

❌ Stuffing the entire form state into Context
   → every keystroke re-renders all subscribers (catastrophic perf)

✅ Limit Context to low-frequency information
   → theme / auth info / i18n (language settings)

For this reason, “putting frequently-changing values in Context is an antipattern.” Form input values or counters should never go on Context. On the other hand, Context is ideal for “rarely-changing values” like theme, auth state, and i18n.

Server state is special

Data fetched from APIs (server state) has fundamentally different properties from UI state, so the same mechanism cannot be used for both. This is the most important principle in modern frontend design.

PropertyUI stateServer state
Source of truthLocalServer
Goes stale?NoYes (re-fetch needed)
Sync needed?NoYes (cache control)
Needed across components?SometimesFrequently

Without understanding this, you end up jamming a fetched user list into Redux, hand-coding “when to refresh” yourself, and falling into the classic ordeal of cache bugs and stale data display. The right move for server state is a dedicated library.

Server state libraries

Dedicated server-state libraries handle the tedious work of cache management, re-fetching, and optimistic updates for you. The modern de facto standard is TanStack Query (formerly React Query).

LibraryCharacteristics
TanStack Query (React Query)De facto. Cache, re-fetch, optimistic updates - all included
SWRFrom Vercel. Lighter and simpler
Apollo ClientGraphQL-only
RTK QueryRedux Toolkit integrated version
const { data, isLoading, error } = useQuery({
  queryKey: ['users', id],
  queryFn: () => fetchUser(id),
  staleTime: 60_000,  // do not re-fetch for 60 seconds
})
  • queryKey manages the cache (same key shares the same cache)
  • staleTime controls re-fetch frequency
  • invalidateQueries invalidates the cache after a mutation, triggering automatic re-fetch

These three mechanisms alone solve the majority of state-management problems.

TanStack Query is the default first choice. It includes everything and the learning cost is reasonable.

URL state

Information like pagination, search conditions, and tab selection is best held in the URL as a modern best practice. Putting it in React state loses the information on browser back or share.

❌ setState({ page: 2, search: "foo" })
   → cannot bookmark, lost on reload

✅ router.push('?page=2&search=foo')
   → bookmarkable, shareable, back-button works

URL state’s benefits:

  • Bookmarkable (the state can be saved)
  • Browser back/forward works (history works naturally)
  • Shareable (just send the URL to reproduce the screen)
  • Server can know the state (can return data on SSR)

The iron rule is “if a value would still matter after a navigation, put it in the URL.”

Form state

For forms that handle user input, dedicated libraries make implementation dramatically easier. Once a form has more than 10 inputs, sticking with useState is unmanageable.

LibraryCharacteristics
React Hook FormUncontrolled, fast, de facto
FormikControlled, classic, somewhat heavy
TanStack FormNew, strong type system
Zod (validation)The decisive schema-driven validation library

The “uncontrolled” approach is a design that does not update React state on every keystroke, so re-renders barely happen even on large forms - making it fast. Today, React Hook Form + Zod is the de facto standard for form implementation.

Controlled forms are fine up to about 10 inputs. Beyond that, React Hook Form is the only choice.

Persistence

Information you want to keep after the app closes is saved to browser persistent storage. There is a clear separation of what goes where, and it directly impacts security.

TargetStorageWhy
Auth infohttpOnly Cookie (recommended)Cannot be stolen via XSS (Cross-Site Scripting, script injection attacks)
Theme settingslocalStorageLow-sensitivity setting values
Draft saveslocalStorage / indexedDBDepends on size
Session cachesessionStorageCleared when tab is closed
Large dataindexedDBCan store MB-scale data

Putting JWT (signed auth token) or session IDs in localStorage is the classic XSS-vulnerability pattern. Storage readable by JavaScript is also readable by attackers, so auth info must always go in an httpOnly Cookie.

Auth info goes in httpOnly Cookie. Putting it in localStorage is a landmine.

”The day I wrote the same fact in two places” (industry stories)

In frontend development, you constantly hear stories like: “I made an items array and a separate itemCount number state, then in the delete handler updated only one of them - that bug bit me over and over.” It could be avoided just by computing items.length on the spot, but people end up splitting the count into a separate state thinking “having the count precomputed seems faster” or “I’ll need it in other places too.” This kind of failure is hit by everyone from new hires to veterans - a classic landmine of state design.

A common anecdote: a similar bug shortly after starting React, getting code-reviewed with “items.length is fine, isn’t it?” The lesson is simple - “if you write the same fact in two places, one of them will eventually become a lie.”

Derive derived state by computation. Put what can be expressed as a URL into the URL. Trust the server with server state. “Keep state minimal” is not abstract aesthetics - it’s a concrete defensive practice learned from past bugs.

The iron rule of state management is “single source of truth.” This one principle dramatically reduces bugs.

State design principles

Here are the design principles for managing the 5 state types well. All matter, but especially “single source of truth” and “derived state by computation” must be followed at any scale.

  • Single Source of Truth: Never hold the same information in two places. Make one primary and the other derived
  • Derive derived state by computation: Don’t keep items.length === 0 as a separate state - compute it on the spot
  • Don’t mix server and client state: Use TanStack Query and Zustand for their respective jobs
  • Keep state minimal: Don’t hold what can be computed (it’s a bug breeding ground)
  • What can be in the URL goes in the URL: Pagination, search conditions

“Put everything in Redux” is the start of breakdown. By project size and state type, combining multiple libraries is the modern standard.

Project scaleUI stateServer stateFormsPersistence
Personal/MVP (~1000 lines)useStateDirect fetch + useStateuseStatelocalStorage
Early startup (~5000 lines)useState + ContextTanStack QueryReact Hook Form + ZodlocalStorage
Mid-size SaaS (~30,000 lines)ZustandTanStack QueryRHF + ZodlocalStorage + httpOnly Cookie
Large SPA (30,000+ lines)Zustand + ContextTanStack QueryRHF + ZodCookie-centric
Next.js App RouterZustand (Client) + RSC (Server)Server Components fetchServer Actions + ZodhttpOnly Cookie

“There’s almost no reason to newly adopt Redux as of 2026” is the industry consensus. Redux Toolkit lingers only on large projects that lean on Redux DevTools’ time-travel debugging, or teams with existing Redux assets. Jotai / Valtio / Signal are also options, but in terms of information volume and adoption, Zustand is a head above the rest.

For new adoption, Zustand + TanStack Query + RHF + Zod. The reasons to choose Redux are narrow.

State management’s pitfalls and forbidden moves

Here are the typical accidents in state design. Every one of them directly causes infinite loops, double updates, and XSS leaks.

Forbidden moveWhy it’s bad
Store JWT in localStorageOne XSS leaks everyone’s tokens. httpOnly Cookie is mandatory
Put server state in Redux and write cache logic by handOld design. TanStack Query/SWR automate it
Hold the same fact in two places (items array + separate itemCount state)One will become a lie. Derive derived state by computation
Frequently-changing values in React ContextAll subscribers re-render. Catastrophic perf
Premature globalization (Zustand when useState would do)Pure overhead with no benefit. Wait until you need it
Manage form input values via ContextRe-renders everything on every keystroke. React Hook Form solves it
Hold values in state that disappear on browser-back/reloadPut them in the URL - shareable and bookmarkable
Pagination/search conditions in useStatePutting them in URL query is the rule. A design where reload destroys them is bad UX
Hand-write data fetching in useEffectLoading/error/race-condition landmines. TanStack Query solves it
Refresh Token in localStorageXSS can hijack sessions on every device. httpOnly Cookie is mandatory
”Put everything in Redux” designMixing server state and UI state and hand-rolling cache logic breaks down. Split tools by type
”Context is a Redux replacement” misconceptionFrequently-changing values cause all subscribers to re-render. Performance catastrophe

The healthy path is “incremental expansion from useState.” Throwing in Redux or Jotai from day one is classic over-design - promote in the order Context → Zustand → Redux Toolkit only as needed.

Premature abstraction is the biggest enemy. Expand state management only when you need to.

AI decision axes

AI-era favorableAI-era unfavorable
Zustand (hook-driven, simple)Redux’s classic 3-file split
TanStack Query + React Hook Form + ZodCustom cache, custom validation
Server Components + Server ActionsFetching everything client-side
URL state (using searchParams)Stuffing-everything-into-state design
  1. Split tools by state type (UI / domain / server / URL / persistent)
  2. Server state gets a dedicated library (TanStack Query — the only choice)
  3. Stick with mainstream stacks (Zustand + TanStack Query + RHF + Zod)
  4. Schema-driven (Zod as the common type language)

What to decide - what is your project’s answer?

For each of the following, try to articulate your project’s answer in 1-2 sentences. Starting work with these vague always invites later questions like “why did we decide this again?”

  • Whether to adopt a global state library, and which one (Zustand / Jotai / Redux)
  • Server state library choice (TanStack Query / SWR)
  • Form library and validation (React Hook Form + Zod)
  • Persistence strategy (Cookie vs. localStorage split)
  • Scope of URL state usage
  • How type definitions are shared (Zod / tRPC / OpenAPI)

Summary

This article covered state management, including the 5 state types, mainstream stacks, URL state, and persistence.

Keep state minimal, split tools by type, lean toward mainstream stacks and schema-driven design. That is the practical answer for state management in 2026.

Next time we’ll cover frameworks in detail (React/Vue/Svelte/Next.js/Astro).

Back to series TOC -> ‘Architecture Crash Course for the Generative-AI Era’: How to Read This Book

I hope you’ll read the next article as well.