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.
On this page
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.
useStateis 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.useReduceris 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:useStateis best for one simple value, anduseReduceris 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
| Kind | Examples | Natural home |
|---|---|---|
| Local UI state | Is this dropdown open? Which tab is active? Hover state | useState / useReducer in the component that needs it |
| Shared client state | Theme, current user session info, a multi-step wizard, a cart before checkout, sidebar collapsed | Lifted state (moved up to a parent), Context (for values that change rarely) or a small store (Zustand, Redux Toolkit, Jotai) |
| Server state | Products, orders, profile data, anything that lives on the server and you only show and cache | A server-state library (TanStack Query, SWR, RTK Query, Apollo) or framework data loading (Server Components) |
| URL state | Search text, filters, sort order, page number, selected tab, open modal ID | The URL (searchParams, route params) |
| Form state | Field values, validation errors, touched/dirty flags (touched means the user visited the field; dirty means the user changed it), submission status | A 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.
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.
- Find the problem: the Network tab shows the same request five times. Each component has its own
useEffectanduseState. - 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. - Tune it: set
staleTimeto match how fresh the data must be. Prefetch when the route changes. Invalidate the key after mutations. - 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
searchParamsand show the right data.
// 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
useStateper 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) anduseFormStatus(a hook that tells a component if its parent form is submitting). UseuseOptimistic(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.
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| Tool | What it is like | Choose it when |
|---|---|---|
useState/useReducer + lifting | Built in, clear | A few nearby components use the state |
| Context | Built in, no selectors | Values for the whole app that rarely change |
| Zustand / Jotai | Tiny, little boilerplate, selectors/atoms | Shared client state without extra ceremony |
| Redux Toolkit | Structured, devtools, middleware (code that runs between an action and the state change, for example to log it), strong conventions | Large 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 this | Complex 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
- 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).
- One source of truth: each piece of data lives in one place. Other places work it out from there.
- Do not store derived data. Work it out during render (memoise it if it is slow).
- Do not copy server data into client state.
- Prefer the URL for state about where the user is (navigation).
- 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.
- Use the simplest tool that works. Move up to a stronger tool only when you feel pain.
- 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.
// ❌ 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
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
- Copying API responses into Redux or Context and writing your own refetch code.
- Everything in one global store, including form fields and UI toggles.
- One giant Context for state that changes fast.
- Storing derived values and syncing them with effects.
- Filters in local state. You lose deep links, back-button behaviour and SSR.
- Several booleans for status instead of a union.
- Avoiding prop drilling with the wrong tool (Context for state that changes often).
- 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
- Frontend and React Scenario Bank · Scenario Debugging
- State and Data Libraries in Depth · Full-Stack Questions
- State and Rendering: What Really Happens When State Changes · React in Depth
Part of the study guide
Related series