Senior Frontend Depth

State Management Decisions: Local, Context, Server Cache, Global Store

How to sort state into kinds (UI, server, URL, form) and pick a tool for each kind, instead of arguing about libraries.

Lesson 0315 min read#react#state#architecture

On this page

  1. Key terms first
  2. The five kinds of state
  3. Server state is not client state
  4. URL state
  5. Form state
  6. Shared client state: Context, or a store?
  7. Principles for placing state
  8. A decision flow
  9. Common mistakes
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: do not ask "Redux or Zustand?" first. Ask "what kind of state is this?" Server data, URL state, form state, shared UI state and local state each have a natural home. Most state-management pain comes from putting the wrong kind of state in the wrong place.

Senior interviews ask about this to see if you can sort state into kinds and pick the simplest tool that solves each case.

Key terms first

  • State is data that a program remembers and that can change over time. When it changes, the screen updates. Example: whether a menu is open.
  • State management means deciding where each piece of state lives and how components read and change it.
  • useState is a React hook (a special function that lets a component use a React feature) that gives a component one remembered value and a function to change it. useReducer is a hook that also remembers a value, but you change it by sending actions (small messages like { type: "add" }) to a reducer (a function that takes the old state and an action and returns the new state). Difference in one line: useState is best for one simple value, and useReducer is best when many kinds of change follow rules.
  • Lifting state up means moving state from a child to the nearest parent, so several children can share it through props.
  • Context is a React feature that lets a parent give a value to every component below it, without passing props through each level.
  • A store is an object that keeps state outside the components, so any component can read it and subscribe to it.
  • A server-state library (TanStack Query, SWR, RTK Query, Apollo) is a library that fetches server data, remembers (caches) it and keeps it fresh for you.
  • A Server Component is a React component that runs only on the server and sends its result to the browser. SSR (server-side rendering) means the server builds the HTML of a page before sending it.
  • A schema is a written description of what valid data looks like. Zod is a library where you write a schema as code and use it to check data and to get its TypeScript type.
  • To memoise means to remember the result of a calculation, and to reuse it when the inputs are the same, instead of calculating again.

The five kinds of state

KindExamplesNatural home
Local UI stateIs this dropdown open? Which tab is active? Hover stateuseState / useReducer in the component that needs it
Shared client stateTheme, current user session info, a multi-step wizard, a cart before checkout, sidebar collapsedLifted state (moved up to a parent), Context (for values that change rarely) or a small store (Zustand, Redux Toolkit, Jotai)
Server stateProducts, orders, profile data, anything that lives on the server and you only show and cacheA server-state library (TanStack Query, SWR, RTK Query, Apollo) or framework data loading (Server Components)
URL stateSearch text, filters, sort order, page number, selected tab, open modal IDThe URL (searchParams, route params)
Form stateField values, validation errors, touched/dirty flags (touched means the user visited the field; dirty means the user changed it), submission statusA form library (React Hook Form, TanStack Form) with a schema (Zod), or native form actions

Two more groups are worth knowing:

  • Derived state: anything you can work out from other state (a filtered list, a total, a full name). Do not store it. Work it out during render (Lesson 01).
  • Persistent state: state that must survive a reload (preferences, drafts). Keep it in localStorage, IndexedDB, cookies or on the server, and sync it on purpose (Browser Contract, Lesson 01).

Server state is not client state

This is the biggest change in thinking in recent years. Data from an API is a copy (a cache) of something you do not own. It can get old (stale). Other users can change it. It can fail to load. You may need to refresh it, avoid duplicate requests, load it page by page, retry it and update the screen before the server answers (an optimistic update). If you build all of this yourself with useState and useEffect (a hook that runs code after the component has rendered, often to fetch data) (or copy the data into Redux), you will create many bugs.

A server-state library gives you all of this, ready to use:

  • Caching by key and deduplication. A cache is a place that keeps a copy of data so you do not fetch it again. A key is a label (like ["product", 7]) that names the data. Deduplication means removing repeated work: five components that ask for the same data share one request.
  • Background revalidation (stale-while-revalidate: show the old data, fetch fresh data quietly). It can also refetch when the user returns to the tab or the network comes back.
  • Loading and error states, retries and cancellation.
  • Mutations (changes sent to the server) with cache invalidation and optimistic updates.
  • Pagination and infinite queries (load data page by page, or keep adding pages as the user scrolls), and prefetching (loading data before the user asks for it).
  • Cleaning up data that nobody uses any more (garbage collection; TanStack Query keeps unused data for 5 minutes by default, called gcTime). This keeps memory use low (see Debugging, Lesson 04).

staleTime is how long fetched data counts as fresh. The default is 0, so data is stale at once and refetches when needed. In the code below, data stays fresh for 60 seconds.

JavaScript
function useProduct(id: string) {  return useQuery({ queryKey: ["product", id], queryFn: ({ signal }) => api.getProduct(id, signal), staleTime: 60_000 });}function ProductHeader({ id }: { id: string }) { const { data } = useProduct(id); /* … */ }function ProductPrice({ id }: { id: string }) { const { data } = useProduct(id); /* … same key: one request, shared cache */ }

In an App Router app, you can often fetch server state in Server Components and pass it down. You then need the client cache only for data that is interactive, changes often, or is changed by the user.

Scenario: five components each fetch the same data. Design the fix.

  1. Find the problem: the Network tab shows the same request five times. Each component has its own useEffect and useState.
  2. Fix it: put the fetch behind one query hook with a key for the resource (["user", id]). The library merges requests that are in flight and shares the cache. All five components read the same data and update together on a refetch. Or fetch once in a Server Component or loader (a function that a router runs to load the data for a page) and pass the data down.
  3. Tune it: set staleTime to match how fresh the data must be. Prefetch when the route changes. Invalidate the key after mutations.
  4. Avoid this: copying the result into a global store. It makes a second cache and causes sync bugs.

URL state

Anything that says what the user is looking at should usually live in the URL:

  • You can share it and bookmark it (send a colleague the filtered view).
  • The back and forward buttons work.
  • It survives a refresh.
  • The server can render it: the server can read searchParams and show the right data.
JavaScript
// Next.js: filters live in the URL, not in useState"use client";import { useRouter, useSearchParams, usePathname } from "next/navigation";function useQueryParam(name: string) {  const sp = useSearchParams(); const router = useRouter(); const pathname = usePathname();  const set = (value: string) => {    const next = new URLSearchParams(sp);    if (value) next.set(name, value);    else next.delete(name);    router.replace(`${pathname}?${next}`, { scroll: false });  };  return [sp.get(name) ?? "", set] as const;}

In the Next.js App Router, wrap a Client Component that calls useSearchParams in a <Suspense> boundary. On a prerendered route (a page built ahead of time), everything up to the nearest boundary is otherwise rendered on the client. This can remove the benefit of prerendering.

searchParams are the ?key=value part of a URL, such as ?sort=price&page=2. Route params are the dynamic parts of the path, such as the 42 in /products/42.

Should filters live in the URL? Yes, for list, search and report pages. You get deep links (links straight to a state) and server rendering. Keep short-lived UI state (a tooltip, an open dropdown) out of the URL. Debounce text input (wait until the user stops typing) before you write it to the URL.

Form state

Forms are hard in their own way. You need validation, tracking of touched and dirty fields, async submit, mapping of errors, and good speed with many fields.

  • Use a form library (React Hook Form with a Zod resolver, or TanStack Form). Do not use one useState per field with your own validation code.
  • A controlled input is a field whose value React keeps in state and sets on every key press. An uncontrolled input is a field whose value the browser keeps; you read it only when you need it. React Hook Form uses uncontrolled inputs. So the whole form does not re-render on every key press (Debugging, Lesson 08).
  • Validate with the same schema as the backend (Backend, Lesson 03). Map the server's field errors back onto the fields.
  • A Server Action is a function that runs on the server and that a form or button can call directly. With Server Actions in the App Router, use useActionState (a hook that gives you the result of an action and a pending flag) and useFormStatus (a hook that tells a component if its parent form is submitting). Use useOptimistic (a hook that shows a temporary value while the real update is still running) for optimistic UI.
  • If drafts must survive a reload, save them locally or on the server.

Shared client state: Context, or a store?

Not all shared state is server state. Some state really belongs to the client and is shared: theme, wizard progress, rows selected across components, a global "command palette is open" flag. It needs a home too.

Context

A consumer is a component that reads a context value. Context is good for app-wide values that change rarely: theme, locale, the current-user object, feature flags, and tools you hand to the whole app (a router, a query client). Context is not a state management library. It is a way to avoid prop drilling (passing props through many layers that do not use them).

When is Context the wrong tool?

  • The value changes often (every key press, mouse move or timer tick). Every consumer re-renders (Debugging, Lesson 03).
  • The value is a big object, and different consumers need different parts. Any change re-renders all consumers, and there are no built-in selectors.
  • You need fine-grained subscriptions, middleware, devtools, persistence or computed values.

Ways to reduce the problem: split contexts by how often they change, memoise the value (with useMemo, a hook that remembers a value between renders), keep state and dispatch in separate contexts, or use a store.

Stores with selectors

Libraries such as Zustand, Redux Toolkit, Jotai and Valtio keep state outside React. Zustand is a small store library. Redux Toolkit is the official, structured way to use Redux, a store where every change is an action. Jotai keeps state in small pieces called atoms. Valtio lets you change a plain object and tracks the changes. Components subscribe to a slice (a small part), so they re-render only when their slice changes. A selector is a small function that picks that slice.

JavaScript
import { create } from "zustand";type Item = { id: string; name: string };const useCart = create<{ items: Item[]; add: (i: Item) => void }>((set) => ({  items: [],  add: (i) => set((s) => ({ items: [...s.items, i] })),}));// inside a component:const count = useCart((s) => s.items.length);        // re-renders only when the length changesconst add   = useCart((s) => s.add);                 // the function never changes, so it never triggers a render
ToolWhat it is likeChoose it when
useState/useReducer + liftingBuilt in, clearA few nearby components use the state
ContextBuilt in, no selectorsValues for the whole app that rarely change
Zustand / JotaiTiny, little boilerplate, selectors/atomsShared client state without extra ceremony
Redux ToolkitStructured, devtools, middleware (code that runs between an action and the state change, for example to log it), strong conventionsLarge teams, complex client state with many interactions, or a need for audit trails
State machines (XState)A state machine lists every allowed state and the moves between them. XState is a library for thisComplex flows (checkout, wizards, media players) with many invalid combinations

Redux is not "wrong". The anti-pattern is using it for server data, form fields and everything else.

Principles for placing state

  1. Colocate: keep state as close as possible to where it is used. Move it up only when two components really need to share it (Debugging, Lesson 03).
  2. One source of truth: each piece of data lives in one place. Other places work it out from there.
  3. Do not store derived data. Work it out during render (memoise it if it is slow).
  4. Do not copy server data into client state.
  5. Prefer the URL for state about where the user is (navigation).
  6. Normalise large shared collections (store them in an object keyed by ID, not in nested lists) if you must keep them on the client. But prefer the server-state cache.
  7. Use the simplest tool that works. Move up to a stronger tool only when you feel pain.
  8. Make impossible states impossible. A union type means "one of these options". Model status as a union ("idle" | "loading" | "success" | "error") or as a reducer or state machine. Do not use several booleans (isLoading && isError).

A discriminated union is a union where each option has a tag field (here status) that tells which option it is.

JavaScript
// ❌ Booleans that can contradict each other{ isLoading: boolean; isError: boolean; data?: T }// ✅ A discriminated uniontype State<T> = { status: "idle" } | { status: "loading" } | { status: "error"; error: Error } | { status: "success"; data: T };

A decision flow

TEXT
Does the data come from a server and can change without you?  → server-state library / Server ComponentDoes it describe what the user is looking at (filter, page, tab)? → URLIs it a form's field/validation/submit state?                  → form libraryIs it computed from other state?                               → derive during renderIs it only used by one component or its children?              → useState / useReducerUsed by distant components and changes rarely?                  → ContextUsed by distant components and changes often?                   → store with selectorsA complex flow with many states and transitions?               → reducer or state machineMust survive a reload?                                         → persist (URL, storage, server)

Common mistakes

  1. Copying API responses into Redux or Context and writing your own refetch code.
  2. Everything in one global store, including form fields and UI toggles.
  3. One giant Context for state that changes fast.
  4. Storing derived values and syncing them with effects.
  5. Filters in local state. You lose deep links, back-button behaviour and SSR.
  6. Several booleans for status instead of a union.
  7. Avoiding prop drilling with the wrong tool (Context for state that changes often).
  8. Over-engineering early: using a heavy store before the state is really shared.

Interview questions

When is Context the wrong tool?

It is wrong when the value changes often, or when it is a large object and consumers need different parts. Every consumer re-renders on any change, and there are no selectors. Context fits values that rarely change, such as theme, locale or the current user. For shared state that changes often, use a store with selectors, or keep the state closer to where it is used.

Server state vs client state?

Server state lives on a remote server. Other people can change it, so it can go stale. It needs caching, deduplication, revalidation, retries and invalidation, and a library such as TanStack Query handles these. Client state belongs to the UI (open modals, wizard steps, theme) and only changes through user actions in the app. If you mix the two, you get duplicate and inconsistent data.

Scenario: five components each fetch the same data. Design the fix.

Put the request in one shared query with a key. Or load it once in a Server Component or loader and pass it down. Then requests are merged and components share one cache. Set a fitting stale time, invalidate after mutations, and do not copy the data into a global store.

Should filters live in the URL?

For list, search and report pages, yes. The view can be shared and bookmarked, back and forward work, it survives a refresh, and the server can render it. Debounce text input updates. Keep short-lived UI state out of the URL.

How do you decide between useState, Context and a store?

Start with useState next to the components that use it. Move it up when sibling components need it. Use Context for app-wide values that rarely change. Use a store with selectors for shared state that changes often or needs middleware and devtools. Choose the simplest tool that solves the problem.

How do you model loading and error states?

Use a discriminated union (idle, loading, success with data, error with the error), or use the status from a library. Then impossible combinations, like loading and error at the same time, cannot happen.

Why avoid storing derived state?

Derived state is a value you can work out from other state. If you store it, you make a second copy of the truth, and you must keep the copies in sync (usually with effects). This causes extra renders and bugs. If you work it out during render (and memoise it if it is slow), you keep one source of truth and the output is always consistent.

When would you choose Redux Toolkit over Zustand?

Choose it when the team and the app gain from Redux's strict conventions, mature devtools, many middleware options, readable action logs and RTK Query. This is usually a large codebase with complex client state and many contributors. For smaller or simpler needs, Zustand or Jotai gives you less boilerplate.

Scenario: a global store holds 200 fields and every update re-renders half the app. How do you fix it?

Components are subscribing to the whole state or to large objects. Select narrow slices with selectors. Split the store by area. Keep server data out of it (use a query cache). Work out computed values instead of storing them. Do not create new objects inside selectors. Profile the store updates, and move data that changes very fast to local state or to refs (useRef boxes that hold a value without causing a render).

Key takeaways

  • Sort the state first: local, shared client, server, URL, form, derived, persistent.
  • Server state belongs in a cache library or in Server Components, not in Redux or useState.
  • State about where the user is belongs in the URL. Forms deserve a form library and a shared schema.
  • Use Context for values that change rarely. Use stores with selectors for shared state that changes often.
  • Colocate, keep one source of truth, derive instead of storing, model status with unions, and use the simplest tool.

What's next

Lesson 04: Advanced TypeScript for Interviews. State is only as safe as its types. Next you learn generics, narrowing, conditional types, and how to keep the API contract honest from server to UI.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series