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;
| Type | Examples |
|---|---|
| UI state | Modal open/close, input values, loading indicators |
| Domain state | Cart contents, favorites list |
| Server state | User list, product details fetched from API |
| URL state | Query parameters, pagination, search conditions |
| Persistent state | Login 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.
| Library | Characteristics |
|---|---|
| Redux / Redux Toolkit | Veteran, huge ecosystem, lots of boilerplate |
| Zustand | Lightweight, hook-driven, simple to write |
| Jotai | Atom-oriented, supports React concurrent mode |
| Recoil | From Facebook but maintenance has stalled - avoid |
| Valtio | Proxy-based, natural feel |
| MobX | Observable-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.
| Property | UI state | Server state |
|---|---|---|
| Source of truth | Local | Server |
| Goes stale? | No | Yes (re-fetch needed) |
| Sync needed? | No | Yes (cache control) |
| Needed across components? | Sometimes | Frequently |
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).
| Library | Characteristics |
|---|---|
| TanStack Query (React Query) | De facto. Cache, re-fetch, optimistic updates - all included |
| SWR | From Vercel. Lighter and simpler |
| Apollo Client | GraphQL-only |
| RTK Query | Redux Toolkit integrated version |
const { data, isLoading, error } = useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
staleTime: 60_000, // do not re-fetch for 60 seconds
})
queryKeymanages the cache (same key shares the same cache)staleTimecontrols re-fetch frequencyinvalidateQueriesinvalidates 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.
| Library | Characteristics |
|---|---|
| React Hook Form | Uncontrolled, fast, de facto |
| Formik | Controlled, classic, somewhat heavy |
| TanStack Form | New, 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.
| Target | Storage | Why |
|---|---|---|
| Auth info | httpOnly Cookie (recommended) | Cannot be stolen via XSS (Cross-Site Scripting, script injection attacks) |
| Theme settings | localStorage | Low-sensitivity setting values |
| Draft saves | localStorage / indexedDB | Depends on size |
| Session cache | sessionStorage | Cleared when tab is closed |
| Large data | indexedDB | Can 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 === 0as 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
Recommended stack by scale
âPut everything in Reduxâ is the start of breakdown. By project size and state type, combining multiple libraries is the modern standard.
| Project scale | UI state | Server state | Forms | Persistence |
|---|---|---|---|---|
| Personal/MVP (~1000 lines) | useState | Direct fetch + useState | useState | localStorage |
| Early startup (~5000 lines) | useState + Context | TanStack Query | React Hook Form + Zod | localStorage |
| Mid-size SaaS (~30,000 lines) | Zustand | TanStack Query | RHF + Zod | localStorage + httpOnly Cookie |
| Large SPA (30,000+ lines) | Zustand + Context | TanStack Query | RHF + Zod | Cookie-centric |
| Next.js App Router | Zustand (Client) + RSC (Server) | Server Components fetch | Server Actions + Zod | httpOnly 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 move | Why itâs bad |
|---|---|
| Store JWT in localStorage | One XSS leaks everyoneâs tokens. httpOnly Cookie is mandatory |
| Put server state in Redux and write cache logic by hand | Old 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 Context | All 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 Context | Re-renders everything on every keystroke. React Hook Form solves it |
| Hold values in state that disappear on browser-back/reload | Put them in the URL - shareable and bookmarkable |
| Pagination/search conditions in useState | Putting them in URL query is the rule. A design where reload destroys them is bad UX |
| Hand-write data fetching in useEffect | Loading/error/race-condition landmines. TanStack Query solves it |
| Refresh Token in localStorage | XSS can hijack sessions on every device. httpOnly Cookie is mandatory |
| âPut everything in Reduxâ design | Mixing server state and UI state and hand-rolling cache logic breaks down. Split tools by type |
| âContext is a Redux replacementâ misconception | Frequently-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 favorable | AI-era unfavorable |
|---|---|
| Zustand (hook-driven, simple) | Reduxâs classic 3-file split |
| TanStack Query + React Hook Form + Zod | Custom cache, custom validation |
| Server Components + Server Actions | Fetching everything client-side |
| URL state (using searchParams) | Stuffing-everything-into-state design |
- Split tools by state type (UI / domain / server / URL / persistent)
- Server state gets a dedicated library (TanStack Query â the only choice)
- Stick with mainstream stacks (Zustand + TanStack Query + RHF + Zod)
- 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).
I hope youâll read the next article as well.
đ Series: Architecture Crash Course for the Generative-AI Era (33/89)