cf.completefrontend
My progress
FUNDAMENTALS · MODULE 10

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.
Before and after. Before: one global store holds the cards, the filters, the open card id, the description draft, teammates' cursors at 20 updates a second each, and whether the sidebar is collapsed; the result is stale copies, lost links, wiped drafts, and slow typing. After: five homes, one per kind: a query cache for the cards, the URL for the filters and open card, the form for the description draft, a buffer for cursors, and a component for the sidebar.Before: one global storecards[]filtersopenCardIddescriptionDraftcursors (20/s each)sidebarCollapsedstale copies, lost linkswiped drafts, slow typingAfter: a home per kindquery cache: cardsURL: filters, open cardform: description draftbuffer: cursorscomponent: sidebareach home brings its own rules
The same six values, two designs. On the left every value gets the same rules; on the right each home brings the rules its kind needs: refetching, sharing and Back, draft ownership, frame batching, and colocation.

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.

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
A map of where state lives. On the server: the database and a realtime hub. In the browser tab, top row: the address bar holding URL state, the query cache holding server state, and the presence buffer holding stream state. Bottom row: form drafts holding form state, and components holding client UI state. The database feeds the query cache by fetch. The hub pushes data changes into the query cache and signals into the presence buffer. The address bar provides the query key. The query cache is copied once into form drafts and saved back. Components read the query cache, and read the presence buffer once per frame.Serverdatabaserealtime hubBrowser tabaddress barURL statequery cacheserver statepresence bufferstream stateform draftsform statecomponentsclient UI statefetchpushcopy once, save backreadper frame
Five homes in one tab. The arrows are the hand-offs between kinds; most bugs live on one of them.

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.

  1. 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.
  2. 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.
  3. Is it something the user is writing and has not saved yet? Then it is form state, owned by the user until they submit.
  4. 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.

A flowchart of five questions asked in order, stopping at the first yes. Can it be computed from other state? Yes: derive it in render, do not store it. Is it pushed fast and never saved? Yes: stream state in a buffer. Does the server own the truth? Yes: server state in a query cache. Should a link or the Back button restore it? Yes: URL state in the path or query string. Is the user drafting it, unsaved? Yes: form state in the draft. If every answer is no, it is client UI state, kept in a component.Computable from other state?yesderive in rendernoPushed fast, never saved?yesstream: buffernoServer owns the truth?yesserver: query cachenoLink or Back restores it?yesURL: path or querynoUser drafting, unsaved?yesform: the draftnoclient UI: componentlift only if needed
Ask in order and stop at the first yes. The first box rules out derived values; the text's first question (who owns the truth?) is split here into fast pushes and stored server data. Whatever is left over is client UI state.

The five kinds side by side

Columns are the kinds; rows are the questions that tell them apart.
QuestionServerClient UIURLFormStream
Who owns the truth?The serverThis tabThe address barThe user, until submitThe server, right now
How long does it live?Forever, in the databaseUntil the tab closes or lessAs long as the link existsUntil submit or cancelSeconds
Shared with other users?YesNoYes, by linkNo (until saved)Yes, live
Survives reload?Yes, refetchedOnly if you persist itYesOnly if you save a draftNo, reconnect
Typical rate of changeSeconds to hoursOn user actionOn navigationOn every keystrokeMany per second
Main riskStale copyToo globalOut of sync with the storeOverwritten by a refetchToo 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 to 0) 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 isLoading flags.
  • Garbage collect. Entries that no mounted component uses are dropped after a while (TanStack Query's gcTime defaults 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.
A sequence diagram with four lifelines: the card list, the column header, the query cache, and the API. The card list asks the cache for cards of board b42. The header asks for the same key. The cache sends one GET to the API, which returns 40 cards. The cache gives the same data to the header and to the card list. 30 seconds later the window regains focus: the cache gives the card list the old copy at once and refetches from the API in the background. When the fresh cards arrive, the cache passes the changes to the card list, which re-renders.Card listColumn headerQuery cacheAPIcards b42same keyone GET40 cardsdatasame data30 s later, the window regains focusold copy, at oncerefetchfresh cardschanges
Two readers, one key, one request. Later, stale-while-revalidate: the old copy shows at once while a background refetch brings the changes. Without a cache each component would fetch on its own, and nothing would refetch on focus.

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

tsx
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] }),
  });
}
Boardly's cards as server state: the key includes everything the request depends on.

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 BoardlyChanges how oftenCost of stalenessReasonable stale time
Workspace name, label listRarelyTinyMinutes, or until a mutation
Cards on the open boardMany times an hourMedium: wrong column0 to 30 s, plus live pushes
Card commentsDuring discussionsMedium0 s with refetch on focus
Billing plan and limitsRarelyHigh if wrong at checkoutRefetch 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.

A component tree. Board is the root, holding the drag in progress, because every column needs it. Its children are Toolbar, holding whether the filter menu is open, and Column, holding collapsed, which both of Column's children read: Header and List. List has one child, Card, which holds isMenuOpen for its own menu.Boarddragging: c17ToolbarfilterMenuOpenColumncollapsedHeaderListboth read collapsedCardisMenuOpen
Each value sits in the lowest component that contains every reader. Toggling a card's menu re-renders one card; collapsing a column re-renders one column.

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 try block. 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 storage event 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.

Boardly's URL split into parts, one per line. app.boardly.com: the origin, not state. /boards/b42: the resource, bookmarkable. /cards/c17: the open card, which Back closes. ?assignee=sam&label=bug: the filters, part of the cards query key. &q=login: the search text, replaced in history while typing. &sort=due: a value to validate against an allow-list. #comment-9: the hash, used only to scroll and never sent to the server.app.boardly.comorigin: not state/boards/b42the resource: bookmark it/cards/c17open card: Back closes it?assignee=sam&label=bugfilters: in the query key&q=loginsearch: replace as you type&sort=duevalidate against a list#comment-9scroll only, not sent
The path names what you are looking at; the query string holds how you are looking at it. Defaults are left out, so /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/c17 or ?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.

tsx
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
  }
  // ...
}
Filters read from the URL and passed straight into the query key: one source, no syncing.

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.
A state machine for the card description form. Pristine goes to Dirty when the user types. Dirty goes back to Pristine on reset, and to Submitting on a valid submit. Submitting goes to Saved on 200 OK, where the cache is updated and the draft cleared, or to Failed on an error. Failed goes back to Dirty when the user edits. A note says that a refetch or push while dirty keeps the draft and shows a notice.PristineDirtySubmittingFailedSavedtypesubmitreset200 OKerroreditrefetch or push while dirty:draft kept, show a noticecache updated,draft cleared
The draft has a life of its own. Server data enters it once, at Pristine, and leaves it once, at Saved. Nothing in between lets a refetch replace what the user typed.

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.

tsx
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>
  );
}
The draft is seeded from the server once, by card id, and never re-synced while editing.

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.
A timeline of 100 milliseconds with a frame every 16.7 milliseconds. 20 cursor messages arrive, one every 5 milliseconds. Rendering once per message, at about 4 milliseconds each, keeps the main thread busy for about 80 of the 100 milliseconds, so typing waits. Writing messages to a buffer and drawing once per frame costs 6 draws of about 2 milliseconds, 12 milliseconds in total, leaving room for input.messagesper message80 ms busy in 100 ms: typing waitsper frame12 ms busy: room for input0 msa frame every 16.7 ms100 ms
Same messages, same screen. Rendering per message spends most of each frame on cursors; a buffer read in 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.

ts
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
    });
  }
});
Receive at any rate, draw once per frame: the latest value per user wins.

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 stateKindHomeWhy
Columns and cardsServerQuery cache, key ["cards", boardId, filters]Lives in the database; teammates change it
Current user and permissionsServerQuery cache, long stale timeOwned by the server; rarely changes
Board idURLPath /boards/b42Names the resource; bookmarkable
Filters, sort, search textURLQuery stringA shared link should show the same view
Open cardURL/boards/b42/cards/c17Shareable; Back should close it
Card description being editedFormThe editor componentThe user's draft until Save
New card title being typedFormThe add-card inputA draft; gone on Escape
Card being dragged, drop targetClient UIDrag context for the boardLasts under a second; this tab only
Sidebar collapsed, densityClient UI (preference)Component state plus localStoragePersonal; nice to remember
ToastsClient UISmall app-level storeRead by one distant toast area
Who is online, cursors, typing hintsStreamExpiring presence store, drawn per frameOnly true right now; high rate
"Card moved" live messageStream on the wire, then serverPatch or invalidate the query cacheIt is a change to stored data
"12 cards match"Not state (derived)Computed in renderCount 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

MistakeSymptom users seeHow you noticeFix
Server data copied into a client storeStale cards, a changed title in one view but not anotherBug reports that say "fixed after reload"Read server data from a query cache; invalidate after mutations
View settings kept only in a storeShared links lose filters; Back leaves the appSupport asks for a screenshot because the link does not reproduceMake the URL the only source for view settings
The same value in the URL and a storeFilters flicker or reset on BackEffects that sync both ways; double fetches in the network panelDelete the store copy; read from the router
A form bound to live server dataTyping is wiped by a refetch or a pushComplaints about lost text, often after switching tabsCopy into a draft once; detect version changes
High-rate stream in a shared storeTyping and dragging lag when many users are onlinePoor INP in field data; long tasks in a performance profileBuffer 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?

One global store for everything

Fetch in effects and dispatch results into the store

Mirror the URL into the store

Keep forms and presence in the store too

Pros

  • One place to look and one devtool
  • Familiar to teams who learned Redux early

Cons

  • You hand-write caching, deduping, refetching, and invalidation
  • Two copies of URL and server data drift apart
  • High-rate updates re-render widely unless every selector is careful

A home per kind

Query cache for server data

Router for URL state

Form state in forms

Component state for UI, a small store only for truly shared UI

Expiring buffer for presence

Pros

  • Each tool brings the right defaults: refetch, Back button, dirty tracking, frame batching
  • Changes re-render only what cares
  • Features delete cleanly

Cons

  • More concepts for new team members
  • Hand-offs between kinds must be named and tested

Server-first framework

Load data on the server per route (React Router loaders, or Next.js with React Server Components)

Mutations through actions that revalidate the route

Client state only for UI, forms, and streams

Pros

  • Very little client code for server state
  • URL drives data loading by design

Cons

  • Fine-grained live updates and offline need extra work
  • Less control over client caching between routes

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 Referer header. Tokens and emails go elsewhere.
  • Treat URL values as untrusted input. Validate them against an allow-list (sort is one of manual, due, title) and fall back to defaults.
  • Be careful what you persist. localStorage can 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".

A wireframe of Boardly at app.boardly.com/boards/b42/cards/c17?label=bug. The toolbar holds filters and search: URL state. Avatars at the top right show who is online: stream state. The collapsible sidebar on the left is UI state. The columns and cards in the middle are server state. The card side panel on the right is opened by the URL; its description box holds a form draft, and its menu being open is UI state.app.boardly.com/boards/b42/cards/c17?label=bugfilters, search: URLonline: streamUIcolumns and cards:server stateopen card: URLdraft: formmenu open: UI
What to draw in the interview: the screen, with every region tagged by its kind. It takes two minutes and answers the caching, routing, real-time, and forms questions before they are asked.

What to say, by level

LevelWhat a strong answer includes
JuniorNames server data versus UI state; keeps server data out of hand-written globals; puts filters in the URL
Mid-levelAll five kinds with homes; stale time and invalidation for server state; push or replace for URL changes; drafts copied once
SeniorThe 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
StaffHow 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.

Question 2

Why not put all of that in one Redux store?

Question 3

A user is editing a card's description and a teammate changes the same card. What happens in your design?

Question 4

Fifty people are on the same board and cursors make typing lag. How do you fix it?

Question 5

How do you decide whether a value belongs in the URL?

References

Read next