Kinds of state
Server data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
The problem: one box for everything
Picture a project-management board, the kind every team uses. We will call it Boardly. It has columns like To do, In progress, and Done, and each column holds cards.
You can filter by person, search by title, open a card in a side panel, and edit its description. You can also see the faces of teammates who are looking at the same board right now.
The first version of Boardly was built fast. The team made one global store and put everything in it: the cards, the columns, the open card id, the search text, the description being typed, the list of people online, and whether the sidebar is collapsed. One box, one place to look. It felt simple.
Six months later the bug tracker looks like this. Each bug below is typical of real apps, and every one comes from the same root cause.
- Stale cards. Priya moves a card to Done. Her teammate Sam still sees it in In progress until he reloads, because his copy of the cards was fetched once at login and never refreshed.
- Lost filters. Sam filters the board to "my cards, label: bug" and sends the link to his lead. The lead opens the link and sees the whole unfiltered board, because the filter lived only in the store.
- Broken back button. Opening a card and pressing Back leaves the app instead of closing the card.
- Wiped drafts. Sam is halfway through a long card description when a background refetch arrives and replaces his text with the saved version.
- Slow typing and dragging. Teammates' cursor positions arrive about 20 times a second. Each update goes into the global store, and many components that read the store render again. Typing in the search box starts to lag.
Five bugs, one cause
None of these is a bug in a library. Each one comes from treating very different values as if they were the same kind of thing.
The cards are a copy of data that lives on a server, so they go stale. The filter is something the user wants to share and bookmark, so it belongs in the URL. The description is a draft that the user owns until they press Save. The cursors are a stream that changes many times a second and is useless a moment later.
Each of those kinds has different needs: how long it lives, who owns the truth, who else needs to see it, and how often it changes. Put them in one box and you get one set of rules for all of them, which is the wrong set for most of them.
This is why system design interviewers listen for the split early. When a candidate says "I'll put everything in Redux", the interviewer hears the five bugs above. When a candidate sorts the state into kinds and gives each one a home, the rest of the design (caching, routing, real-time, forms) follows from that choice.
What counts as state
State is any value that can change while the page is open and that changes what the user sees. The title of a card is state. So is whether a menu is open, which card is selected, the text half-typed into a box, and the green dot that says a teammate is online.
Two kinds of values are not state, and spotting them early saves a lot of code. Constants never change while the page is open, such as the list of column colors. Derived values can be computed from other state, such as "12 cards match your filter": store the cards and the filter, and compute the count when you render. Storing a derived value next to its sources means two copies that can disagree.
Check your understanding · Exercise 1
Which of these Boardly values should be stored as state (not computed and not a constant)? Select all that apply.
The mental model: five kinds, five homes
Almost every value in a frontend app falls into one of five kinds. Each kind has a natural home: the place that owns the truth and the tool built for it. Learn these five and you can sort any app's state in a few minutes.
- Server state. Data that lives in a database and that you only hold a copy of: Boardly's cards, columns, members, and comments. Home: a server-state cache (a library such as TanStack Query, SWR, RTK Query, or Apollo, or the cache in your framework).
- Client UI state. Values that only this tab cares about and that nobody would share: is the sidebar collapsed, which menu is open, which card is being dragged. Home: the component that uses it, or a small store if many distant components need it.
- URL state. Values that describe what the user is looking at and that should survive a reload, a shared link, and the Back button: the board id, the filters, the search text, the open card. Home: the path and the query string.
- Form state. A draft the user is writing that is not saved yet, plus its bookkeeping: the values, which fields were touched, which are invalid, and whether a save is in flight. Home: the form itself, often with a form library.
- Stream state. Values pushed by the server many times a second that are only true right now: who is online, where their cursors are, a "Priya is typing" hint. Home: a small buffer outside the render tree, read at a steady rate.
Four questions that sort any value
You do not need to memorize lists of examples. First rule out derived values and constants, as in the first chapter. Then four questions decide the kind of almost any value: ask them in order and stop at the first yes.
- Who owns the truth? If the answer is a server (the value is in a database, and other users can change it), it is server state, or stream state if it changes many times a second and is never stored.
- Should it survive a reload or a shared link? If a teammate who opens your link should see the same thing, it is URL state.
- Is it something the user is writing and has not saved yet? Then it is form state, owned by the user until they submit.
- Otherwise it is client UI state: local to this tab, cheap to lose, and usually local to one component.
Two more properties help with edge cases. Lifetime: does the value matter for a frame, a minute, a session, or forever? Rate: does it change once a minute or 60 times a second? A value that changes 60 times a second needs special handling whichever kind it is.
The five kinds side by side
| Question | Server | Client UI | URL | Form | Stream |
|---|---|---|---|---|---|
| Who owns the truth? | The server | This tab | The address bar | The user, until submit | The server, right now |
| How long does it live? | Forever, in the database | Until the tab closes or less | As long as the link exists | Until submit or cancel | Seconds |
| Shared with other users? | Yes | No | Yes, by link | No (until saved) | Yes, live |
| Survives reload? | Yes, refetched | Only if you persist it | Yes | Only if you save a draft | No, reconnect |
| Typical rate of change | Seconds to hours | On user action | On navigation | On every keystroke | Many per second |
| Main risk | Stale copy | Too global | Out of sync with the store | Overwritten by a refetch | Too many renders |
An everyday comparison
Think of a shared office. The company records in the filing room are server state: you can take a photocopy to your desk, but the copy ages, and you go back to the filing room when it matters. The room number on your meeting invite is URL state: anyone with the invite finds the same room.
The letter you are drafting is form state: it is yours until you send it, and nobody should replace it with an old copy. Your desk layout is client UI state: nobody else cares. The voices in the open-plan area are stream state: useful right now, pointless to file away.
Check your understanding · Exercise 2
Using the four questions, which kind is "the board is sorted by due date" in Boardly? Pick one.
Server state: a cache, not a variable
The single most useful idea in this lesson: the client never owns server data. It holds a cache of it. The cards Sam sees are a photo of the database taken when the request returned. From that moment the photo starts to age, because Priya, a bot, or another tab can change the real thing.
Once you think of it as a cache, the questions you need to answer become clear. They are the same questions you ask of any cache: how is it keyed, when is a copy too old, when do we fetch again, and what do we do when we change the data ourselves.
The jobs a server-state cache does
- Key each piece of data. A request is stored under a key such as
["cards", { boardId: "b42", assignee: "sam" }]. Two components that ask for the same key share one entry and one request. - Remove duplicate requests. If the column header asks for the board while the card list's request for the same key is still in flight, no second request goes out. See the request deduplication lesson.
- Track freshness. Each entry records when it was fetched. A setting called stale time (TanStack Query's
staleTime, which defaults to0) says how long the copy counts as fresh. Once it is stale, the next refetch trigger (a component mounting, the window regaining focus) shows the old copy at once and fetches a new one in the background. This pattern is called stale-while-revalidate. - Refetch at the right moments. When the window regains focus, when the network comes back, when a mutation (a request that changes data) touches the data, or on an interval.
- Handle loading and errors. Every entry has a status: pending, success, or error, plus whether a background fetch is running. The UI reads that status instead of keeping its own
isLoadingflags. - Garbage collect. Entries that no mounted component uses are dropped after a while (TanStack Query's
gcTimedefaults to 5 minutes), so memory does not grow forever. - Apply your own changes. After a mutation, either invalidate the affected keys so they refetch, or update the cache directly, possibly before the server answers. That second option is the optimistic updates lesson.
The classic mistake: copying it into a client store
The Boardly team did this: fetch the cards in a useEffect, then dispatch(setCards(data)) into a global store. Now there are two copies of the cards: the server's truth and the store's photo. Nothing ties them together. Nobody wrote code to refetch on focus, nobody handles two requests for the same board arriving out of order, and every mutation must remember to patch the store by hand.
Each of those jobs is solvable, but solving them all is exactly writing a server-state cache. Libraries that do this are covered in the server-state libraries lesson in the data fetching module. In a design interview it is enough to say "server data lives in a query cache keyed by board and filter, with a 30 second stale time, refetch on focus, and invalidation after mutations".
function useBoardCards(boardId: string, filters: Filters) {
return useQuery({
queryKey: ["cards", boardId, filters],
queryFn: ({ signal }) => api.listCards(boardId, filters, { signal }),
staleTime: 30_000, // fresh for 30 s, then refetch in the background
});
}
function useMoveCard() {
const qc = useQueryClient();
return useMutation({
mutationFn: api.moveCard,
onSettled: (_data, _err, vars) =>
qc.invalidateQueries({ queryKey: ["cards", vars.boardId] }),
});
}
Notice two details. The query key includes the filters, so "Sam's bug cards" and "all cards" are separate entries and switching between them is instant once both are loaded. And the request receives an abort signal, so a filter change cancels the old request. The request races lesson explains why that matters.
How fresh is fresh enough?
Stale time is a product decision, not a technical one. Ask: how bad is it if the user sees a value that is N seconds old?
| Data in Boardly | Changes how often | Cost of staleness | Reasonable stale time |
|---|---|---|---|
| Workspace name, label list | Rarely | Tiny | Minutes, or until a mutation |
| Cards on the open board | Many times an hour | Medium: wrong column | 0 to 30 s, plus live pushes |
| Card comments | During discussions | Medium | 0 s with refetch on focus |
| Billing plan and limits | Rarely | High if wrong at checkout | Refetch before any paid action |
For data that changes often and matters, polling or a push channel keeps the cache close to the truth. Pushed updates are stream state on the wire, but they usually end by writing into the server-state cache. We come back to that in the stream state chapter.
Check your understanding · Exercise 3
Sam returns to Boardly after 10 minutes in another tab. With a query cache using a 30 s stale time and refetch on window focus, what does he see? Pick one.
Client UI state: keep it close and small
Client UI state is everything the user does to the interface that nobody else needs to know about. In Boardly: the sidebar is collapsed, the "More" menu on a card is open, the user is hovering a column, a card is mid-drag, a toast is showing, keyboard focus is on the third card.
It is the easiest kind to handle and the easiest to over-engineer. Most of it is short-lived and used by one component, so the right home is that component's own state: useState in React, a ref in Vue, a signal in Solid or Angular.
Rule one: put it as low as it can go
Keep each piece of UI state in the lowest component that contains every reader of it. This is called colocation. If only the card menu reads isMenuOpen, the state lives in the card menu. If the column header and the card list both need collapsed, it lives in the column, their nearest shared parent.
Colocation has two payoffs. A change re-renders only the small subtree that cares, which keeps typing and dragging fast. And deleting a feature deletes its state with it, so there are no orphaned flags in a global store that nobody dares to remove.
When UI state really is global
Some UI state is read by distant parts of the page: the theme, whether the command palette is open, the toast queue, the current drag (so drop targets across columns can highlight). A context or a small store is fine for these. Keep that store small and only for UI state, never a second copy of server data.
Where the line sits between local and global, and how context, Zustand, Redux, and signals compare, is the topic of the local vs global state lesson later in this module. For this lesson the rule is: start local, lift only when a second distant reader appears.
Preferences: UI state that is remembered
Some UI state should survive a reload but is still personal: the theme, the collapsed sidebar, the board density. These are preferences. They are client UI state with a persistence layer, usually localStorage. If the user expects them on every device, they become server state (a user settings record) with a local copy for instant first paint.
- Read the stored value before the first paint, or the page flashes the default theme and then switches. For a server-rendered page that means a small inline script or a cookie the server can read.
- Wrap reads in a
tryblock. Storage can throw in some private browsing modes and when the quota is full. - Version the stored value (
{ v: 2, density: "compact" }) so a later release can ignore or migrate old shapes. - If two tabs can change it, listen for the
storageevent so the other tab updates. The cross-tab sync lesson covers this.
Check your understanding · Exercise 4
Only the card component and its "More" menu read whether that menu is open. Where should isMenuOpen live? Pick one.
URL state: what the user is looking at
The URL is the oldest state store on the web, and the only one the browser already knows how to save, share, bookmark, and undo. URL state is anything that answers "what am I looking at?": which board, which filters, which sort, which page of results, which tab, which card is open.
The test is simple. If Sam copies the link and Priya opens it, should she see the same view? If Sam presses Back, should this change be undone? If yes to either, the value belongs in the URL.
/boards/b42 alone is a valid view.Path or query string?
- The path names the resource:
/w/acme/boards/b42. It should change rarely and be stable enough to bookmark for months. - The query string holds the view settings on top of it:
?assignee=sam&label=bug&q=login&sort=due. Each one is optional and has a default, so a bare path still works. - A nested path or a query key can hold an overlay such as an open card:
/boards/b42/cards/c17or?card=c17. Both work; the path form reads better and lets the server render the card for a shared link. - The hash (
#comment-9) scrolls to a part of the page. The browser does not send it to the server, so the server cannot render based on it.
The URL is the source, not a mirror
The most common URL bug is keeping the filters in a store and in the URL, then writing code to sync them both ways. Two-way sync loops: the store updates the URL, the URL change updates the store, and a stray effect fires twice. Pressing Back changes the URL but not the store, and the board shows the wrong filter.
Make the URL the only copy. Components read the filters from the router (useSearchParams or your framework's equivalent) and write by navigating. Nothing else stores them. The URL then feeds the server-state key directly: change ?label=bug and the query key changes, so the cache fetches or reuses the matching data.
function BoardPage({ boardId }: { boardId: string }) {
const [params, setParams] = useSearchParams();
const filters = {
assignee: params.get("assignee") ?? "anyone",
label: params.get("label") ?? "all",
sort: params.get("sort") ?? "manual",
};
const cards = useBoardCards(boardId, filters); // server state, keyed by URL state
function setLabel(label: string) {
const next = new URLSearchParams(params);
if (label === "all") next.delete("label");
else next.set("label", label);
setParams(next); // pushes a history entry, so Back undoes it
}
// ...
}
Push or replace?
Every URL change either pushes a new history entry (Back undoes it) or replaces the current one (Back skips it). Pick per value. Opening a card or changing the filter is a step the user may want to undo: push.
Typing in the search box would add one entry per keystroke: replace while typing, or update the URL once the user pauses. Scroll position is not URL state. The browser and many routers restore it on Back, and long infinite lists may need to save it in history.state (see the pagination lesson).
Check your understanding · Exercise 5
Which Boardly values belong in the URL? Select all that apply.
Form state: a draft the user owns
When Sam opens a card and starts editing its description, a new kind of state appears. The text in the box is not the card's description yet. It is a draft: the user's own work, which may become server state when they press Save, or vanish when they press Cancel.
Form state is that draft plus the bookkeeping around it. Treating it as its own kind is what fixes the "wiped drafts" bug from the first chapter.
What form state holds
- Initial values: a snapshot of the server data at the moment editing began. They are used to reset the form and to tell what changed.
- Current values: what is in the fields now.
- Dirty: whether a field differs from its initial value. It drives the "unsaved changes" warning and enables the Save button.
- Touched: whether the user has visited and left a field. Show errors only after a field is touched, or a user sees a red "required" before typing a single letter.
- Errors: the result of validation, per field and for the whole form.
- Submit status: idle, submitting, succeeded, or failed with a server error to show.
Copy once, then let go of the server
The key rule: when editing starts, copy the server value into the form once, and stop listening to it. From then on the user owns the draft. The bug in Boardly came from binding the text box directly to the server-state cache. A background refetch (focus, interval, or a live update) replaced the cached description, and the text box followed it, wiping Sam's work.
Copying once means a teammate can change the same card while Sam edits. That is a real conflict, and hiding it is worse than showing it. A good design notices that the server version moved since editing began (compare a version number or updatedAt), and tells Sam "Priya changed this card while you were editing" with a choice, instead of silently dropping one person's work.
function DescriptionEditor({ card }: { card: Card }) {
// key={card.id} on this component resets the draft when a different card opens
const [initial] = useState(card); // snapshot taken once
const [draft, setDraft] = useState(initial.description);
const save = useSaveDescription();
const dirty = draft !== initial.description;
return (
<form onSubmit={(e) => {
e.preventDefault();
save.mutate({ id: card.id, description: draft, baseVersion: initial.version });
}}>
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
{card.version !== initial.version && <p>This card changed while you were editing.</p>}
<button disabled={!dirty || save.isPending}>Save</button>
</form>
);
}
After submit, the draft becomes server state
When the save succeeds, the value moves kinds. Update or invalidate the server-state cache so every view shows the new description, then clear or close the draft. If you want the board to update before the server answers, that is an optimistic update: see the optimistic updates lesson for the rollback rules.
Long drafts deserve protection. Warn on leaving the page with unsaved changes (the beforeunload event), and for long text consider saving the draft to localStorage keyed by card id, so a crash or an accidental close does not lose ten minutes of writing. Controlled vs uncontrolled inputs, validation timing, and schema validation are covered in the forms at scale lesson.
Check your understanding · Exercise 6
Sam is editing a card's description. A live update arrives with Priya's new description for the same card. What should happen? Select all that apply.
Stream state: true for a moment
The last kind arrives on its own, without the user asking. Boardly shows the faces of teammates viewing the board, their cursors as they move, a "Priya is typing" hint on a card, and a live badge when someone moves a card. All of it is pushed over a WebSocket or server-sent events.
Stream state is pushed data whose value is only true right now and that may change many times a second. Its defining traits are a high rate and a short life. Nobody stores yesterday's cursor positions, and a cursor update that is 2 seconds late is worse than useless.
Two kinds of pushed messages
Not every pushed message is stream state. Split what arrives on the socket into two groups, because they go to different homes.
- Changes to stored data, such as "card c17 moved to Done" or "a comment was added". The truth is in the database; the push is only a fast notice. Write it into the server-state cache (patch the entry, or invalidate the key so it refetches). Then every view of that card updates, and a reload shows the same thing.
- Ephemeral signals, such as cursor positions, presence, and typing hints. They are never stored in a database. Keep them in a small, separate store built for a high rate, and let them expire: a cursor not updated for 5 seconds disappears, a typing hint clears after 3 seconds.
requestAnimationFrame draws only the latest positions, at most once per frame. Render costs are illustrative.The rate problem
Ten teammates moving their mouse, each sending 20 updates a second, is 200 messages a second. If each one goes through a global store and every subscriber re-renders, the main thread spends its time rendering cursors and the user's own typing starts to lag. That is the "slow typing" bug from the first chapter, and it shows up as a poor INP (Interaction to Next Paint, the Web Vital that measures how fast the page responds to input).
The fix is to separate receiving from rendering. Messages land in a plain buffer (a Map from user id to the latest position) with no re-render. Once per animation frame (usually 60 times a second, more on high refresh rate screens), the cursor layer reads the latest value for each user and draws.
Older values in between are simply overwritten. Only the cursor layer subscribes, so the board and the search box never re-render for a cursor.
const latest = new Map<string, { x: number; y: number; at: number }>();
let scheduled = false;
socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
if (msg.type !== "cursor") return handleDataChange(msg); // goes to the query cache
latest.set(msg.userId, { x: msg.x, y: msg.y, at: Date.now() });
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
drawCursors(latest); // one paint for any number of messages
});
}
});
Senders help too: throttle cursor updates on the client to about 15 to 30 per second, and send nothing while the mouse is still. The receiver can smooth the gaps by animating between positions. The presence and live cursors lesson goes further.
When the stream breaks
Streams drop: laptops sleep, trains go into tunnels. Ephemeral signals are easy: clear them all on disconnect and let them rebuild after reconnect.
Changes to stored data are harder, because any card move that happened while offline was missed. The safe move on reconnect is to refetch (invalidate) the board's server-state entries, or ask the server for every change since the last event id. See the WebSockets and server-sent events lessons for reconnection.
Check your understanding · Exercise 7
A WebSocket message says "card c17 title changed to 'Fix login'". Where should it go? Pick one.
Sorting every piece of Boardly
Now put it all together. Below is every piece of state on Boardly's main screen, with its kind, its home, and the reason. Read the reasons more than the answers: in an interview, the reason is what earns the credit.
| Piece of state | Kind | Home | Why |
|---|---|---|---|
| Columns and cards | Server | Query cache, key ["cards", boardId, filters] | Lives in the database; teammates change it |
| Current user and permissions | Server | Query cache, long stale time | Owned by the server; rarely changes |
| Board id | URL | Path /boards/b42 | Names the resource; bookmarkable |
| Filters, sort, search text | URL | Query string | A shared link should show the same view |
| Open card | URL | /boards/b42/cards/c17 | Shareable; Back should close it |
| Card description being edited | Form | The editor component | The user's draft until Save |
| New card title being typed | Form | The add-card input | A draft; gone on Escape |
| Card being dragged, drop target | Client UI | Drag context for the board | Lasts under a second; this tab only |
| Sidebar collapsed, density | Client UI (preference) | Component state plus localStorage | Personal; nice to remember |
| Toasts | Client UI | Small app-level store | Read by one distant toast area |
| Who is online, cursors, typing hints | Stream | Expiring presence store, drawn per frame | Only true right now; high rate |
| "Card moved" live message | Stream on the wire, then server | Patch or invalidate the query cache | It is a change to stored data |
| "12 cards match" | Not state (derived) | Computed in render | Count the filtered cards |
Lab: sort the state yourself
Drag each piece of Boardly's state into a bucket, or select it and press a bucket button. The lab tells you what goes wrong when a value lands in the wrong home. Several wrong homes "work" on day one; the lab shows which bug they cause later.
Drag a piece of state onto a bucket, or select it and press a bucket's button. 0 of 0 placed right, 9 left.
home: query cache
home: path or query string
home: the form's draft
home: expiring buffer
home: component or small store
Values that change kind
A value can move from one kind to another during its life, and the moment it moves is where bugs hide. Name the hand-offs in your design.
- Server to form: opening the editor copies the server value into a draft, once.
- Form to server: Save turns the draft into a mutation; on success the cache updates and the draft is cleared.
- URL to server: the filters in the URL become part of the query key, which picks the server data to show.
- Stream to server: a pushed change patches or invalidates the cache.
- Client UI to server: a preference the user wants on every device is saved as a settings record.
Gray areas and how to decide
Some values honestly fit two kinds, and saying so in an interview is a good sign. The right answer depends on a product question, so ask it.
- The selected cards (multi-select). Usually client UI state, because nobody shares a selection. If bulk actions should survive a reload, it could go in the URL, but a long list of ids makes ugly links.
- The current page of an infinite list. Pages loaded so far are server state in the cache. Which page the user is on can be URL state for a paged table (
?page=3) but is usually not for an infinite feed. The pagination lesson discusses this. - A board's column widths. Client UI preference if personal, server state if the board owner sets them for the whole team.
- The wizard step in a multi-step form. Form state if a reload may restart it; URL state (
?step=3) if Back should move between steps.
Check your understanding · Exercise 8
Boardly adds a "Show archived cards" toggle above the board. Which homes are reasonable? Select all that apply.
Conflating kinds: the most common design flaw
Almost every state bug in a mature frontend is two kinds of state forced into one home, or one value kept in two homes. These mistakes look harmless in a demo, because a demo has one user, one tab, fast Wi-Fi, and no reloads. They surface in production, one support ticket at a time.
The five conflations to look for
| Mistake | Symptom users see | How you notice | Fix |
|---|---|---|---|
| Server data copied into a client store | Stale cards, a changed title in one view but not another | Bug reports that say "fixed after reload" | Read server data from a query cache; invalidate after mutations |
| View settings kept only in a store | Shared links lose filters; Back leaves the app | Support asks for a screenshot because the link does not reproduce | Make the URL the only source for view settings |
| The same value in the URL and a store | Filters flicker or reset on Back | Effects that sync both ways; double fetches in the network panel | Delete the store copy; read from the router |
| A form bound to live server data | Typing is wiped by a refetch or a push | Complaints about lost text, often after switching tabs | Copy into a draft once; detect version changes |
| High-rate stream in a shared store | Typing and dragging lag when many users are online | Poor INP in field data; long tasks in a performance profile | Buffer outside the render tree; draw once per frame |
A sixth, quieter mistake is storing derived values: keeping doneCount next to the cards, or filteredCards next to cards and filters. Every update must now remember to fix both, and one day it will not. Compute derived values in render, and memoize them (cache the result until the inputs change) only if profiling shows the computation is slow.
Card c17 on the server: In progress. On Sam's screen: In progress.
Pick a design, then fire events in any order.
Why teams fall into it
Conflation usually starts with a reasonable wish for one place to look, often from tutorials written before server-state libraries were common. It grows because each new feature copies the pattern next to it. Moving server data out of a store after two years means touching every screen, so it pays to choose homes on day one, which is exactly what an interviewer is checking.
Decision How should Boardly organize its state overall?
What we pick: For Boardly, a home per kind: a query cache for cards and members, the router for board, filters, and the open card, form state inside editors, local state for UI with one small store for toasts and drag, and an expiring presence buffer. A server-first framework is a fine base for the same split; it changes who loads server data, not the kinds.
Security, performance, and accessibility angles
- Never put secrets or personal data in the URL. URLs are kept in history, server logs, and analytics tools, and a lax referrer policy can send the full URL to other sites in the
Refererheader. Tokens and emails go elsewhere. - Treat URL values as untrusted input. Validate them against an allow-list (
sortis one ofmanual,due,title) and fall back to defaults. - Be careful what you persist.
localStoragecan be read by any script on the origin, so a cross-site scripting (XSS) bug exposes it. Keep auth tokens out of it, and clear user data on logout, especially on shared computers. - Server data cached on the client is per user. On logout, clear the query cache so the next person on the same device does not see the last person's board.
- Focus is UI state too. When the open card closes because the URL changed (including via Back), move keyboard focus back to the card that opened it, so keyboard and screen reader users do not lose their place.
- Rate decides re-render cost. Fast-changing values (drag position, cursors, scroll) should not live in state that many components read.
Check your understanding · Exercise 9
A bug report: "After I pressed Back, the board shows the 'bug' label filter, but the filter menu says 'All labels'." What is the likely cause? Pick one.
Real products and the interview
The five kinds are not a theory invented for interviews. Look at any well-built product and you can see the split in its address bar and its behavior.
How real products split their state
- GitHub issues keeps the whole search in the query string, for example
?q=is:issue is:open label:bug. Copy the address and a teammate sees the same list. The issues themselves are server data. - Trello gives every open card its own URL:
trello.com/c/, a short id, then a readable version of the card's title. Opening a card from the board changes the address, so a card can be shared and Back closes it. - Figma and Google Docs show other people's cursors and selections. These are ephemeral: they are not part of the saved document, and they disappear when the person leaves.
- Linear, in talks about its sync engine, has described its client as keeping a local copy of the workspace data that syncs with the server in the background, so most reads do not wait for the network. That is server state with a much stronger cache, not a reason to mix kinds.
When to bring it up
In a frontend system design interview, sort the state right after you sketch the main components and before you discuss the API in detail. It takes two or three minutes and it shapes every later answer: caching follows from server state, routing from URL state, real-time from stream state, and validation from form state.
Draw a small table or five labelled boxes next to your component diagram. List each piece of state with its kind and home. Then point at the hand-offs: "the filters in the URL are part of the cards query key", "live card moves patch the cache", "the editor copies the card once".
What to say, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Names server data versus UI state; keeps server data out of hand-written globals; puts filters in the URL |
| Mid-level | All five kinds with homes; stale time and invalidation for server state; push or replace for URL changes; drafts copied once |
| Senior | The hand-offs between kinds, conflict handling for edits, high-rate streams kept out of the render tree, logout clearing, gray areas resolved with a product question |
| Staff | How the split scales across teams (who owns which query keys, URL contracts between features), and how to migrate an app that put everything in one store |
Interview pitfalls
- Opening with a library name ("I'll use Redux") before saying what the state is. Name the kinds first; tools follow.
- Forgetting the URL entirely. Interviewers often follow up with "what happens when I share this link?"
- Treating live updates as a separate data source instead of a way to keep the server-state cache fresh.
- Over-sorting: you do not need to classify every hover flag. Cover the pieces that drive the design.
Check your understanding · Exercise 10
The interviewer asks: "How do you keep the board up to date when teammates make changes?" Which answers fit the five-kinds model? Select all that apply.
Summary
- State comes in five kinds: server, client UI, URL, form, and stream. Each has a natural home with the right rules built in.
- Server data is a cache of the truth, never the truth. Key it, track its freshness, refetch it, and invalidate it after changes.
- If a shared link or the Back button should reproduce it, it belongs in the URL, and the URL is its only copy.
- A form is a draft the user owns: copy server data in once, never re-sync while editing, and detect conflicts by version.
- Pushed changes to stored data go into the server cache; ephemeral signals go into a small, expiring buffer drawn once per frame.
- Keep UI state as low in the tree as it can go; lift it only when a distant reader appears. Compute derived values instead of storing them.
- Conflation (one value in two homes, or two kinds in one home) is the most common state design flaw. Name the hand-offs between kinds instead.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Walk me through the kinds of state in a project board like Trello, and where each lives.
Cards, columns, members, and comments are server state: the database owns them, so the client holds a cache keyed by board and filters, with a short stale time, refetch on focus and reconnect, and invalidation after mutations.
The board id, filters, sort, search, and the open card are URL state, so links reproduce the view and Back works. The filters feed the cards query key.
The card editor holds form state: a draft copied from the server once, with dirty, touched, errors, and submit status. Presence and cursors are stream state in an expiring buffer drawn once per frame. Menus, drag, and toasts are local UI state; theme and density are persisted preferences.
Question 2
Why not put all of that in one Redux store?
A store can hold anything, but it brings no rules for any kind. For server data I would hand-write fetching, dedupe, freshness, refetch, and invalidation, which is a query cache. For URL state I would have two copies to keep in sync with the router. For streams I would re-render subscribers at the message rate.
A store is still a good home for UI state that many distant components share. I keep it small and for that only.
Question 3
A user is editing a card's description and a teammate changes the same card. What happens in your design?
The editor copied the description into a draft when editing began, along with the card's version. The live update patches the query cache, so the board shows the teammate's change, but the draft is untouched.
The editor sees that the cached version is newer than its base version and shows a notice. On save, the request sends the base version; if the server sees a conflict it rejects or merges, and the user chooses. Nobody's text is silently lost.
Question 4
Fifty people are on the same board and cursors make typing lag. How do you fix it?
Cursors are stream state. I move them out of any shared store into a plain map of user id to latest position, updated on each message with no render. A single cursor layer reads the map once per animation frame and draws.
Senders throttle to about 20 updates a second and send nothing while idle; stale cursors expire after a few seconds. I would confirm the fix with INP in field data and a performance profile.
Question 5
How do you decide whether a value belongs in the URL?
Two questions: if I share this link, should the other person see the same thing? If I press Back, should this change be undone? A yes means URL.
Then I pick push or replace per value, keep defaults out of the URL, validate every value read from it, and never put secrets or personal data in it.
References
- Choosing the state structure (react.dev) Avoiding redundant and duplicated state.
- You might not need an effect (react.dev) Computing derived values instead of syncing them.
- TanStack Query: important defaults Stale time, refetch on focus, and garbage collection in a real server-state cache.
- URLSearchParams (MDN) Reading and writing the query string.
- History API (MDN) Push versus replace and the Back button.
- Interaction to Next Paint (web.dev) Why high-rate updates that block the main thread hurt responsiveness.
Read next
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.