Intermediate to senior

Frontend Interview Prep

Fourteen chapters on HTML and CSS, core JavaScript, the event loop, browser rendering, React, state, performance, accessibility, security, TypeScript, testing, machine-coding components and frontend system design, with tested code.

Chapter 8 of 14Browser and React · State Management and Data Fetching

State Management and Data Fetching

"Where should this state live?" is the question behind most frontend architecture. The answer depends on what kind of state it is, who needs it, and how long it lives. This chapter gives you a taxonomy, the patterns for each kind, and the reasoning to defend a choice, including how to treat server data, forms and URL state.

1. Kinds of state

KindExamplesBest home
Local UI stateis the menu open, the current input text, hoveruseState in the component
Shared UI statethe selected tab used by siblings, a wizard's steplift to the nearest common parent
Global client stateauth user, theme, feature flags, a shopping cart before checkoutcontext or a small store
Server stateproducts, profile, notifications: data owned by the backenda server-state cache (TanStack Query, SWR, RTK Query, Apollo)
URL statesearch query, filters, page, sort, selected itemthe URL (search params, route params)
Form statefield values, errors, touched, submittinga form library or local state
Persistent client statedraft text, preferences, offline datalocalStorage, IndexedDB, with sync

The most common mistake is putting server state into a global client store and re-implementing caching, loading flags, retries and invalidation by hand. Treat server data as a cache of remote truth with its own tools.

2. A decision flow

<!--fig:stateflow-->
Where should this state live? Ask in order: Can it be derived?compute it, do not store it Used by one component?local state Used by a few nearby?lift it to the parent Comes from the server?query cache Should survive refresh or be shared?URL or storage Distant, changes rarely?context Distant, changes often?store with selectors Figure 1. A state placement checklist. Stop at the first yes.
  1. Can it be derived from other state or props? Then do not store it; compute it.
  2. Is it needed by one component? Local state.
  3. Needed by a few nearby components? Lift it up.
  4. Needed in distant parts but changes rarely? Context.
  5. Changes often and is read in many places? A store with selectors (Zustand, Redux Toolkit, Jotai) so components subscribe only to what they use.
  6. Comes from the server? A query cache.
  7. Should survive a refresh or be shareable by link? URL (or storage).

Single source of truth: store the minimum (for example selectedId, not a copy of the selected object), and derive the rest.

3. Prop drilling, composition and context

Prop drilling passes data through components that do not use it. Before reaching for global state, try composition: pass elements as children or props so the middle layers do not need to know.

// instead of threading `user` through Layout and Sidebar, build the part in the owner:
<Layout sidebar={<UserMenu user={user} />}>{page}</Layout>

Context solves true cross-cutting data. Its weakness is that all consumers re-render when the value changes. Remedies: split contexts (state and dispatch separately), memoise the value, keep fast-changing data out of context, or use a store with selectors.

4. Stores: Redux, Zustand and friends

A store holds state outside components; components subscribe to slices. Redux popularised the unidirectional flow: an action describes what happened, a pure reducer computes the next state, subscribers re-render.

function createStore(reducer, initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,
    dispatch(action) {
      state = reducer(state, action);
      listeners.forEach(l => l());
    },
    subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
  };
}
const cartReducer = (s = { items: {} }, a) => {
  if (a.type === "add") return { items: { ...s.items, [a.id]: (s.items[a.id] ?? 0) + 1 } };
  if (a.type === "remove") { const { [a.id]: _, ...rest } = s.items; return { items: rest }; }
  return s;
};
const store = createStore(cartReducer);
let notifications = 0;
const unsubscribe = store.subscribe(() => notifications++);
store.dispatch({ type: "add", id: "p1" });
store.dispatch({ type: "add", id: "p1" });
store.dispatch({ type: "add", id: "p2" });
assert.deepEqual(store.getState().items, { p1: 2, p2: 1 });
store.dispatch({ type: "remove", id: "p1" });
assert.deepEqual(Object.keys(store.getState().items), ["p2"]);
unsubscribe();
store.dispatch({ type: "add", id: "p3" });
assert.equal(notifications, 4);                       // no notification after unsubscribing

Selectors

A component should subscribe to the smallest slice it needs. A selector derives data from the store; memoised selectors avoid recomputing and avoid returning a new reference when the data has not changed.

function createSelector(inputSelector, compute) {
  let lastInput, lastResult, initialised = false;
  return state => {
    const input = inputSelector(state);
    if (!initialised || input !== lastInput) { lastInput = input; lastResult = compute(input); initialised = true; }
    return lastResult;
  };
}
let computes = 0;
const totalItems = createSelector(s => s.items, items => { computes++; return Object.values(items).reduce((a, b) => a + b, 0); });
const stateA = { items: { a: 1, b: 2 }, ui: 1 };
assert.equal(totalItems(stateA), 3);
assert.equal(totalItems({ ...stateA, ui: 2 }), 3);    // unrelated change: the same items reference, no recompute
assert.equal(computes, 1);

Redux versus lighter stores: Redux Toolkit gives conventions, devtools and middleware, good for large teams and complex flows. Zustand and Jotai give less boilerplate. Pick the least machinery that handles the problem, and do not use a global store for state that belongs in one component.

5. Server state with a query cache

Server data is asynchronous, shared, can go stale, and is owned elsewhere. A query library provides:

  • Caching by key: ["todos", { status: "open" }].
  • Deduplication: two components asking for the same key trigger one request.
  • Loading, error and success states, refetching, retries.
  • Stale-while-revalidate: show cached data immediately, refetch in the background.
  • Invalidation after mutations, optimistic updates, pagination and infinite scrolling, prefetching.
function createQueryCache(fetcher, staleMs = 50) {
  const entries = new Map();
  let requests = 0;
  async function get(key) {
    const now = Date.now();
    const hit = entries.get(key);
    if (hit?.promise) return hit.promise;                          // dedupe in-flight requests
    if (hit && now - hit.at < staleMs) return hit.data;           // fresh
    const promise = fetcher(key).then(data => { entries.set(key, { data, at: Date.now() }); return data; });
    entries.set(key, { ...(hit ?? {}), promise });
    promise.finally(() => { const e = entries.get(key); if (e) delete e.promise; });
    requests++;
    return promise;
  }
  return { get, invalidate: key => entries.delete(key), stats: () => ({ requests }) };
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const cache = createQueryCache(async key => { await sleep(5); return key.toUpperCase(); });
const [x, y] = await Promise.all([cache.get("a"), cache.get("a")]);
assert.equal(x, "A"); assert.equal(y, "A");
assert.equal(cache.stats().requests, 1);                           // two components, one request
await cache.get("a");
assert.equal(cache.stats().requests, 1);                           // served from the fresh cache
await sleep(60);
await cache.get("a");
assert.equal(cache.stats().requests, 2);                           // stale: refetched

Optimistic updates

Update the UI immediately, send the mutation, and roll back if it fails. Show failure clearly.

async function optimisticToggle(state, id, apiCall) {
  const previous = state.items.find(i => i.id === id).done;
  const apply = done => { state.items = state.items.map(i => (i.id === id ? { ...i, done } : i)); };
  apply(!previous);                          // optimistic
  try { await apiCall(); } catch { apply(previous); return false; }   // roll back on failure
  return true;
}
const uiState = { items: [{ id: 1, done: false }] };
assert.equal(await optimisticToggle(uiState, 1, async () => {}), true);
assert.equal(uiState.items[0].done, true);
assert.equal(await optimisticToggle(uiState, 1, async () => { throw new Error("offline"); }), false);
assert.equal(uiState.items[0].done, true);   // rolled back to the previous value

Pagination patterns

PatternProsCons
Offset/page (?page=3)simple, jumpableinconsistent when data changes; slow for large offsets
Cursor-based (?after=<id>)stable under inserts, efficientno random page access
Infinite scrollgood for feedsaccessibility, footer access, memory (virtualise)
"Load more" buttonexplicit, accessibleone more click

6. URL as state

Filters, search, sort, page and selected tabs belong in the URL when users expect back, forward, refresh and sharing to work. Treat the URL as the source of truth and derive UI from it. Debounce typing into the URL and use replace rather than push for incremental changes.

function parseQuery(search) {
  const p = new URLSearchParams(search);
  return { q: p.get("q") ?? "", page: Math.max(1, Number(p.get("page")) || 1), tags: p.getAll("tag") };
}
assert.deepEqual(parseQuery("?q=shoes&page=3&tag=red&tag=sale"), { q: "shoes", page: 3, tags: ["red", "sale"] });
assert.deepEqual(parseQuery("?page=abc"), { q: "", page: 1, tags: [] });      // bad input falls back to defaults

7. Forms

Forms combine state, validation, async submission and accessibility.

  • Validate on blur and on submit, not on every keystroke before the user has finished. Show errors next to fields and link them with aria-describedby.
  • Track touched, dirty and submitting states; disable the submit button while submitting but do not rely on that alone to prevent duplicates (also idempotency keys on the server).
  • Always validate on the server, since client checks are only for convenience.
  • Libraries (React Hook Form, Formik) with schema validators (Zod, Yup) reduce boilerplate; uncontrolled inputs avoid re-rendering on every key.
  • Preserve input on error; support autofill and paste; use correct type, inputmode and autocomplete attributes.
function validateSignup({ email, password }) {
  const errors = {};
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Enter a valid email address";
  if (password.length < 8) errors.password = "Use at least 8 characters";
  return errors;
}
assert.deepEqual(validateSignup({ email: "[email protected]", password: "longenough" }), {});
assert.deepEqual(Object.keys(validateSignup({ email: "nope", password: "short" })), ["email", "password"]);

8. Persistence and sync

  • localStorage is synchronous, string-only, same-origin and readable by any script on the page (so no secrets). Use it for small preferences.
  • IndexedDB for larger or structured data and offline support.
  • Cross-tab sync with the storage event or BroadcastChannel.
  • Offline-first apps queue mutations and reconcile later, which needs conflict handling (last write wins, merge, or CRDTs).
  • Version persisted shapes and write migrations, because old data outlives your code.

9. Common mistakes

  • Duplicating state (a stored filteredList that drifts from list and filter); derive it instead.
  • Server data in a global store with hand-written loading flags.
  • A single giant context that re-renders everything.
  • Storing derived values or whole objects where an ID suffices.
  • Filters not reflected in the URL, so refresh loses them.
  • No error and empty states.
  • Optimistic updates with no rollback.
  • Trusting client validation.
  • Putting secrets in localStorage.

10. Practice questions

  1. Classify the state in a shopping app: where does each piece live?
  2. When is context enough, and when would you introduce a store?
  3. How is server state different from client state? What does a query library give you?
  4. Implement a small Redux-style store with subscribe.
  5. How would you implement optimistic updates with rollback?
  6. Offset versus cursor pagination: trade-offs?
  7. What belongs in the URL? Why?
  8. How would you design a form with validation, error display and accessibility?
Header Logo