The Seven Kinds of State
Local UI, form, URL, server, authentication, cached and persistent state have different owners, lifetimes and truths. Calling them all "state" is the first bug.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What kind of state is this, and who should own it?
A person is using an application: filtering a list, opening a panel, typing into a form, staying logged in across a refresh. They do not think about any of this. They think "the thing I did should still be there".
State is state. Put it all in one place — a store at the top of the app — so any component can read it and any component can change it. One mental model, one debugger tab, done.
A filter lives in the store, so the URL never changes. The user filters a 4,000-row table down to the one order they need, copies the link into a ticket, and the colleague who opens it sees an unfiltered table with no idea what was meant.
- A filter lives in the store, so the URL never changes. The user filters a 4,000-row table down to the one order they need, copies the link into a ticket, and the colleague who opens it sees an unfiltered table with no idea what was meant.
- A list of orders lives in the store because it "is state", so it is fetched once and never refreshed. Two people work the same queue; the second one sees a row that was completed twenty minutes ago and works it again.
- A dropdown's open/closed flag lives in the store. It is now global, so two instances of the component on one page open and close together, and the store's history is 80% dropdown toggles (Debugging State).
- A form draft lives in the same object as the saved record, so a half-typed edit is indistinguishable from the persisted value — and a background refetch overwrites what the user is typing (State Synchronization).
- An access token lives in the store, and the store is persisted to
localStoragefor convenience. Any script that runs on the page can now read it, and it survives a logout in another tab (Storage Security and Durability). - The store rehydrates from storage on load, so a user who was demoted last week comes back and gets the admin navigation until the next request fails (Authorization-Aware UI).
What is actually happening
In the browser, not in the framework.
- The categories differ on four axes, and those axes — not the API surface — are what make them different things: who is authoritative, how long it lives, who else can change it, and what a wrong value costs.
- Local UI state is authoritative in the component, lives as long as the component is mounted, and nothing else can change it. An open panel, a hovered row, a focused tab index. Its correct value is whatever the last interaction said.
- Form state is a *draft*: a staging area for a mutation that has not happened. Its authority is the user, and it is deliberately allowed to disagree with the server until submit (Form State Is a Draft).
- URL state is authoritative in the address bar, lives as long as the entry is in history, and is changed by the user pressing Back as much as by your code. It is the only category the browser itself can restore (The URL Is Application State).
- Server state is not yours at all. The client holds a *copy* that was true at the moment of the response, and other people are changing the original while you look at it (Server State Is Not Your State).
- Authentication state is a claim the server issues and the server verifies. The client renders from it; it never decides from it (What the Frontend Is Responsible For in Auth).
- Cached state is server state plus an explicit freshness policy: a key, an age, and a rule for when to revalidate (The Client Cache Model).
- Persistent client state is whatever you deliberately wrote to cookies, Web Storage, IndexedDB or Cache Storage so it survives the page — with a lifetime measured in months and no schema migration unless you wrote one (Persistent Client State).
What this makes the browser do
And which of it is avoidable.
- Local UI state costs the browser one re-render of the smallest subtree the framework can identify, plus whatever style, layout and paint that subtree's change invalidates (What a Component Costs to Render).
- URL state costs a history entry and, in a client-side router, a route match plus whatever the route's loaders trigger (Client-Side Routing).
- Server state costs a request, a parse, and a re-render — and, if it is not deduplicated, one of each per component that asked (Five Components, One Request).
- Persistent client state costs synchronous main-thread work for Web Storage (it blocks) and asynchronous transactions for IndexedDB (it does not), which is why the two are not interchangeable at size (Choosing Browser Storage).
- Putting everything in one store makes every change a candidate for every subscriber. Frameworks mitigate this with selectors and fine-grained reactivity, but the mitigation is work you now have to get right (Reactivity Models).
Seven categories, four axes
The reason "state" is a useless word in a design discussion is that it merges values with completely different owners. A dropdown's open flag and a customer's account balance are both "state" and share nothing else: not their authority, not their lifetime, not who else can change them, not what happens if the value is wrong.
Read the table by the columns, not the rows. The interesting content is not the list of names — it is that no two rows agree on who is authoritative, and that is the axis every downstream decision hangs from.
| Category | Authoritative source | Lifetime | Who else changes it | Symptom when misfiled |
|---|---|---|---|---|
| Local UI state | The component itself | While mounted | Nobody | Two instances of a component share one open panel |
| Form state (draft) | The person typing | Until submit or discard | A refetch, if you let it | A background refresh eats what the user typed |
| URL state | The address bar and history | While the entry exists | The user, via Back and Forward | A filtered view cannot be shared or bookmarked |
| Server state | The server | Until the next write, anywhere | Every other user of the system | A completed row is worked twice |
| Authentication state | The server, verified per request | Until expiry or revocation | An admin, a logout in another tab | UI shows admin controls to a demoted user |
| Cached state | A copy plus a freshness policy | Until invalidated or evicted | Your own invalidation rules | A mutation succeeds and the list still shows the old value |
| Persistent client state | Whatever you last wrote to storage | Months, across sessions and releases | Other tabs, and your previous release | A returning user loads a shape the new code cannot read |
One bucket versus seven answers
The one-bucket design is not stupid; it is the only design that needs no vocabulary. Everything is reachable, everything is inspectable in one devtools panel, and no one has to argue about where a value goes. It fails at the point where the categories start behaving differently — which is the point where real users, real tabs and real time enter the picture.
The rewritten version is not "use more libraries". It is the same application with each value placed according to who is authoritative for it. Note that three of the seven need no state management at all: the URL is state management, and so is a cookie.
store = {
orders: [], // fetched once, never refreshed
filter: 'open', // invisible to the URL
selectedId: null, // invisible to the URL
editDraft: {}, // same object shape as an order
isPanelOpen: false, // global, so every instance shares it
user: { role: 'admin' }, // persisted, so it survives a demotion
}orders -> cached server state (key, staleness, invalidate on mutate) filter -> URL search param (shareable, bookmarkable, Back works) selectedId -> URL search param (a link opens the same order) editDraft -> form state in the editing component (never the record) isPanelOpen -> local state in the panel component (per instance) user -> auth state, re-verified server-side on every request recentIds -> persistent client state, versioned, non-authoritative
Each line in the second block answers a different question about authority, and each answer removes a specific class of bug: unshareable views, stale rows, overwritten drafts, coupled component instances, and UI that trusts a client-side role claim. The first block cannot express those differences at all, so it cannot fix them individually.
What each misfiling actually looks like in production
These are not hypotheticals; they are the standard shapes. What makes them hard is that the bug reports never mention state — they say "the link didn't work", "it showed me the wrong thing", "it lost my changes". Recognising the category error is the entire diagnosis.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Server data held in a client store with no freshness policy | "It showed me an order that was already done" | The copy was true at fetch time; the original changed and nothing revalidated it | Give it a key, a staleness rule and an invalidation trigger (Query Keys and Invalidation). |
| Filter and selection held in memory instead of the URL | "I sent the link and they saw something different" | The view is not addressable, so there is nothing to send | Move shareable, restorable state into search params (The URL Is Application State). |
| A form bound directly to the cached record | "It deleted what I was typing" | A background revalidation replaced the object the inputs read from | Edit a draft copy; reconcile only on submit (Form State Is a Draft). |
| Per-instance UI flags lifted into a shared store | "Opening one row opens all of them" | One value now backs many renders of the same component | Keep interaction state inside the component that renders it (Drawing Component Boundaries). |
| Role or permission claims persisted to storage | "A former admin still saw the admin menu" | An authorization decision was cached client-side across sessions | Render from a freshly verified claim; enforce server-side always (Authorization-Aware UI). |
| Persisted state written without a version tag | "It broke for returning users only, right after the release" | Last month's shape rehydrated into this month's code | Version the payload and discard or migrate unknown versions (Persistent Client State). |
How to build it
Most important first.
- Ask "who is authoritative for this value?" before choosing where to put it. That single question sorts almost everything correctly, and it is the whole of Who Owns This State?.
- Keep local things local. A component's own interaction state should not be visible to anything that does not render it; that is not purity, it is the reason two instances can coexist.
- Put shareable and restorable state in the URL. Filters, tab selection, pagination, sort order and the selected item are URL state far more often than teams assume (The URL Is Application State).
- Treat anything that came from a server as a cache with a freshness policy, not as a value you own. Give it a key and an invalidation rule the day you fetch it (Query Keys and Invalidation).
- Keep the draft separate from the record. A form edits a copy; the copy becomes the record only when the server says so (Form State Is a Draft).
- Persist deliberately and narrowly, with a version tag, and never persist authorization or identity decisions (Persistent Client State).
- Choose the sharing mechanism — props, context, or a shared store — as a separate, later decision about *how far* the state must travel, not as the definition of what it is (Prop Drilling, Context and Global State).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A state change that is not announced does not exist for a screen-reader user. Filtering a table from 4,000 rows to 12 is a visually obvious change and a completely silent one unless the result count is in a live region (Live Regions and Announcement).
- Category choice decides whether the Back button works. URL state means Back is a real undo for navigation-shaped changes; store state means Back leaves the page entirely and destroys the user's work. That is a usability and an accessibility property at once (History and Navigation).
- Local UI state usually owns focus: which item is active in a listbox, which tab is selected. If that state is lifted somewhere shared and reset by an unrelated update, focus jumps and a keyboard user loses their place (Focus Management).
- Authentication state drives whether controls are absent, present-and-disabled, or present-and-explained. A disabled control with no accessible name for *why* it is disabled is a dead end for everyone and an unlabelled one for assistive technology (Authorization-Aware UI).
What can go wrong
- Category confusion: server data held in a client store with no freshness policy. It never looks broken in development, where you reload constantly, and is permanently stale in a tab someone left open all day (Long-Lived Clients and Version Skew).
- One value duplicated into two categories — say, a selected id in both the URL and a store — with no rule for which wins. They diverge the first time the user presses Back (Derived State).
- Over-correction: everything moved into the URL, including a transient hover state, producing a history stack the Back button cannot walk sensibly.
- The mitigation failing: a well-designed category split undone by one convenience call that reaches into the global store from a leaf component, which now cannot be rendered anywhere else.
- Persisted state outliving the schema that produced it, so a release ships and a returning user loads a shape the new code does not understand (Persistent Client State).
- A background refetch of server state landing while the user is editing a form built from it: the draft is overwritten mid-keystroke unless the two categories are kept separate (Form State Is a Draft).
- A URL change from the Back button arriving while a fetch triggered by the previous URL is still in flight; the older response resolves last and repaints the previous view (Out-of-Order Responses).
- Two tabs writing the same persisted key. Last write wins, and neither tab is told, unless you listen for the storage event (Auth Across Tabs).
- Authentication state expiring between the render that showed a control and the request that control sends (Session Expiry and the Refresh Race).
- The browser enforces nothing about state categories. Every value in every category is readable and writable by the user and by any script running on the page (Cross-Site Scripting).
- Authentication state on the client is a rendering input, never an authorization decision. The server re-checks every request regardless of what the UI believed (What the Frontend Is Responsible For in Auth).
- Persisted state is the highest-exposure category by default: it survives the session, it is readable by same-origin script, and it is often forgotten at logout (Storage Security and Durability).
- URL state is the most *shared* category — it lands in browser history, in referrer headers, in server access logs, in chat messages and in screenshots. Never put anything in a query string you would not publish (The URL Is Application State).
- "So I need a state library." You need an ownership answer. Most categories are served by the platform and the framework you already have; a library is an implementation of a policy, not the policy.
- "Global state is bad." Shared client state is a legitimate answer for values genuinely needed by distant, unrelated parts of the UI. It is a bad *default*, which is a different claim (Prop Drilling, Context and Global State).
- "Server state is just state I fetched." It is a copy of something other people are still changing. The distinction is the entire reason caching libraries exist (Server State Is Not Your State).
- "Persisting the store makes the app feel faster." It makes the app start from a snapshot of unknown age, including permissions and prices, which is a correctness decision wearing a performance costume.
- "The category is obvious from the data type." It is not. The same array of orders is server state in a table, cached state behind a query key, and persistent state in an offline queue — the difference is who is authoritative, not what it contains.
Measuring it, and what changes in the field
- Framework devtools show component state and store contents; a store whose action log is dominated by UI toggles is telling you a category is in the wrong place (Debugging State).
- The Network panel answers "is this server state being refetched, deduplicated, or never refreshed at all" faster than reading the code does (Five Components, One Request).
- Application → Storage in devtools shows exactly what you persisted and how large it grew, which is usually more than the team remembers writing (localStorage and sessionStorage).
- The address bar is a free measurement: perform a meaningful action and see whether the URL changed. If it did not, that state cannot be shared or restored.
- On a long-lived tab, server state held without a freshness policy becomes arbitrarily stale, and authentication state may have expired without the UI noticing (Session Expiry and the Refresh Race).
- With a large dataset, the difference between deriving a filtered view and storing it becomes the difference between one array pass and a synchronisation problem (Derived State).
- On a slow network, the gap between "the client's copy" and "the server's value" is wide enough for the user to act inside it, which is where optimistic UI and rollback live (Optimistic UI).
- On a memory-constrained device, the browser may discard the tab entirely; only URL state and persisted state survive that (The Multi-Process Browser).
- Seven categories is more vocabulary than "state", and it means more decisions per feature. The payment is that each decision is small and local; the alternative is one large decision nobody makes explicitly and everybody debugs later.
- Putting state in the URL exposes it — to logs, to history, to anyone the link is sent to. Sharing is exactly the feature and exactly the risk.
- Treating server data as a cache introduces freshness policy, keys and invalidation: real machinery for a problem you otherwise pretend does not exist.
- Strict category separation can mean the same conceptual value appears in two places (a draft and a record), which is duplication you are choosing on purpose, with an explicit rule for reconciling it.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe seven categories are properties of browser applications, not of any framework: the URL, the network, the cookie jar and the storage APIs impose them. A vanilla-JS application has exactly the same seven and exactly the same ownership question.
- FRAMEWORK-SPECIFICOnly the expression differs. React and Solid declare local state as a hook or signal call (
useState,createSignal), Vue as aref/reactiveobject, Svelte 5 as a$staterune the compiler turns into plain assignments, Angular as a signal or a component field read during change detection. The invalidation granularity differs sharply — React re-runs the whole component function, while Solid, Svelte and Angular signals update only the bindings that read the value — but the category boundaries are identical in all five.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a browser holding a copy of server data while other clients write to the original is a replication problem with one replica per tab, and the vocabulary of staleness, convergence and conflict resolution applies directly.
- — Software Design — "who owns this value" is the same question as module ownership of mutable data; the categories here are that principle applied to a browser's specific lifetimes.