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
| Kind | Examples | Best home |
|---|---|---|
| Local UI state | is the menu open, the current input text, hover | useState in the component |
| Shared UI state | the selected tab used by siblings, a wizard's step | lift to the nearest common parent |
| Global client state | auth user, theme, feature flags, a shopping cart before checkout | context or a small store |
| Server state | products, profile, notifications: data owned by the backend | a server-state cache (TanStack Query, SWR, RTK Query, Apollo) |
| URL state | search query, filters, page, sort, selected item | the URL (search params, route params) |
| Form state | field values, errors, touched, submitting | a form library or local state |
| Persistent client state | draft text, preferences, offline data | localStorage, 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-->- Can it be derived from other state or props? Then do not store it; compute it.
- Is it needed by one component? Local state.
- Needed by a few nearby components? Lift it up.
- Needed in distant parts but changes rarely? Context.
- Changes often and is read in many places? A store with selectors (Zustand, Redux Toolkit, Jotai) so components subscribe only to what they use.
- Comes from the server? A query cache.
- 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
| Pattern | Pros | Cons |
|---|---|---|
Offset/page (?page=3) | simple, jumpable | inconsistent when data changes; slow for large offsets |
Cursor-based (?after=<id>) | stable under inserts, efficient | no random page access |
| Infinite scroll | good for feeds | accessibility, footer access, memory (virtualise) |
| "Load more" button | explicit, accessible | one 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,inputmodeandautocompleteattributes.
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
localStorageis 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
storageevent orBroadcastChannel. - 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
filteredListthat drifts fromlistandfilter); 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
- Classify the state in a shopping app: where does each piece live?
- When is context enough, and when would you introduce a store?
- How is server state different from client state? What does a query library give you?
- Implement a small Redux-style store with
subscribe. - How would you implement optimistic updates with rollback?
- Offset versus cursor pagination: trade-offs?
- What belongs in the URL? Why?
- How would you design a form with validation, error display and accessibility?