State Management Trade-offs in Frontend Apps
Sort state by where it belongs before picking a library: server cache, URL, form, local UI or shared store, and why Context re-renders every consumer.
"State" in a frontend app is several different things that only look alike: a cache of data that really lives on a server, values that belong in the URL, a half-typed form, a menu that is open, and a few values that many components share. Each kind has a natural home with different rules for freshness, sharing and persistence. Most state-management pain comes from putting one kind in another kind's home, and the most common mistake is treating React Context as a store: every component that reads a context re-renders whenever its value changes.
Context
In the jQuery era the DOM was the state: you read a checkbox to know if something was selected. Backbone (2010) moved state into models, and React (2013) flipped the direction so the UI is a function of state. Facebook then published Flux (2014) for state that many components share, and Redux (2015) turned it into one store, plain actions and pure reducers. For years "state management" meant "which global store". Three later shifts changed the question: the stable Context API in React 16.3 (2018) and hooks in 16.8 (2019) made local and injected state easy; React Query (2019, now TanStack Query) and SWR showed that most "global state" was really a cache of server data; and signals came back through SolidJS, Preact Signals (2022) and Angular 16 (2023), with a TC39 proposal at stage 1 since 2024.
You have met the basic problem the first time two sibling components needed the same value. The fix is lifting state up to their closest common parent and passing it down:
function Filters() {
// both children need it, so it lives in the parent
const [query, setQuery] = useState('')
return (
<>
<SearchBox value={query} onChange={setQuery} />
<ResultList query={query} />
</>
)
}- Server state
- A local copy of data owned by a server. It can go stale, needs refetching, and is shared with every other user of that server.
- Client state
- Data that exists only in this browser tab: an open dialog, a selected row, a theme choice.
- Prop drilling
- Passing a value through components that do not use it, only so a deeply nested child can.
- Store
- An object outside the component tree that holds state and notifies subscribers when it changes (Redux, Zustand, MobX).
- Selector
- A function that picks the slice of a store a component cares about, so it re-renders only when that slice changes.
- Signal
- A reactive value that tracks who reads it and updates exactly those readers when it changes, without re-running whole components.
Why it matters
Misplaced state shows up as bugs that are hard to trace back to the cause. A list copied from the API into a global store goes stale because nothing knows to refetch it. Filters held in memory vanish on reload and cannot be shared as a link. A theme stored in the same Context as the current user makes the whole app re-render on every keystroke in a profile form. And a useEffect that copies one piece of state into another adds an extra render and a window where the two disagree.
Classify state before choosing a tool
Ask three questions about each value: who owns it (a server, the URL, the user right now), who needs it (one component, a subtree, the whole app), and how long it must survive (this render, a reload, forever). The answers place it.
Server state is the big one. It is not yours: it can change on the server at any moment, so it needs caching, deduplication, background refetching and invalidation after mutations. That is a cache problem, and a query library solves it with far less code than a store plus hand-written loading flags. In a React Server Components app much of it never reaches the client at all. What remains for a client store is usually small: the signed-in user's session details, a cart before checkout, a multi-step wizard, an editor's selection.
// the store becomes a hand-made, always-stale cache
const useStore = create((set) => ({
projects: [],
loading: false,
load: async () => {
set({loading: true})
const res = await fetch('/api/projects')
set({projects: await res.json(), loading: false})
},
}))
// who refetches after a mutation? on focus? dedupes?// cached by key, deduped, refetched on focus/mount
const {data: projects, isPending} = useQuery({
queryKey: ['projects'],
queryFn: () => fetch('/api/projects').then(r => r.json()),
})
// after a mutation, mark it stale and it refetches
queryClient.invalidateQueries({queryKey: ['projects']})Why Context re-renders every consumer
Context is dependency injection: it makes a value available to a subtree without passing props. It has no notion of slices. When the provider's value changes by identity, React re-renders every component that called useContext for it, whether or not it used the part that changed. Put the user, the theme and a setter in one object, and a theme toggle re-renders every component that only wanted the user.
const AppContext = createContext(null)
function AppProvider({children}) {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
// new object every render -> every consumer re-renders
const value = {user, setUser, theme, setTheme}
return <AppContext value={value}>{children}</AppContext>
}
function Avatar() {
const {user} = useContext(AppContext)
return <img src={user.avatarUrl} alt="" />
} // re-renders on every theme changeimport {create} from 'zustand'
const useApp = create((set) => ({
user: null,
theme: 'light',
setTheme: (theme) => set({theme}),
}))
function Avatar() {
// subscribes to user only
const user = useApp((s) => s.user)
return <img src={user.avatarUrl} alt="" />
} // a theme change does not touch itTwo fixes exist. Within Context, split rarely-changing values from frequently changing ones into separate providers and memoize each value with useMemo. Or keep the data in an external store and let each component subscribe through a selector: the store calls every subscriber on each change, but React only re-renders a component when its selected value is different by Object.is. React 18 (2022) added useSyncExternalStore so that this subscription stays consistent with concurrent rendering; Zustand and React Redux are built on it.
A store in twenty lines
- 1Keep the state in a closure outside React, with a
Setof listener functions. - 2
setStatereplaces the state immutably and calls every listener. - 3A hook passes
subscribeand a snapshot getter touseSyncExternalStore, which re-renders the component only when the snapshot changes. - 4The snapshot is the selector applied to the state, so a component that selects a primitive or a stable reference is skipped when other fields change.
import {useSyncExternalStore} from 'react'
export function createStore<T>(initial: T) {
let state = initial
const listeners = new Set<() => void>()
const subscribe = (fn: () => void) => {
listeners.add(fn)
return () => listeners.delete(fn)
}
const setState = (update: (s: T) => T) => {
state = update(state)
listeners.forEach((fn) => fn())
}
function useStore<S>(select: (s: T) => S): S {
return useSyncExternalStore(subscribe, () => select(state))
}
return {useStore, setState, getState: () => state}
}
// select primitives or stable references: a selector that builds a
// new object each call never compares equal and loopsStores, atoms and signals
Libraries for shared client state differ mainly in how a change finds the code that depends on it. A single store (Redux, Zustand) holds one object and filters with selectors. Atoms (Jotai, the now-archived Recoil) split state into many small units that components subscribe to individually, and derived atoms recompute only when their inputs change. Signals go furthest: a read inside a template or effect registers a dependency automatically, and an update reruns only that binding. In SolidJS components run once and only the text node that reads a signal updates, which is why signals skip most of the re-render cost that the rest of this page works around.
| Tool | Model | What re-runs on change | Good fit |
|---|---|---|---|
| Context | Injected value | Every consumer of that context | Rarely changing values: theme, locale, auth client |
| Redux Toolkit | One store, actions, reducers | Components whose selector result changed | Large teams, strict event log, devtools time travel |
| Zustand | One store, plain setters | Components whose selector result changed | Most shared client state with little ceremony |
| Jotai | Atoms + derived atoms | Components reading the changed atoms | Many independent pieces, fine-grained derivation |
| Signals | Reactive values, auto-tracked | Only the bindings that read it | Solid, Preact, Angular, Vue refs; very frequent updates |
| Query cache | Cache keyed by request | Components using that key | Anything that comes from a server |
URL, forms and derived state
If a value should survive a reload, be shareable as a link or work with the back button, it belongs in the URL: search filters, sort order, the selected tab, the page number, an open detail panel. The URL is then the single source of truth, and the component reads from it instead of mirroring it in useState.
'use client'
import {useSearchParams, useRouter, usePathname} from 'next/navigation'
export function StatusFilter() {
const params = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const status = params.get('status') ?? 'all' // read, don't copy
function setStatus(next: string) {
const q = new URLSearchParams(params)
next === 'all' ? q.delete('status') : q.set('status', next)
router.replace(`${pathname}?${q}`, {scroll: false})
}
return <Select value={status} onChange={setStatus} />
}Forms are local state with special needs: validation, dirty and touched flags, and submission status. A form library keeps those per field and avoids re-rendering the whole form on each keystroke; for a few fields, useState or uncontrolled inputs read on submit are enough. Neither belongs in a global store.
Finally, anything that can be computed from existing state should be computed, not stored. Storing it creates two sources of truth and an effect to keep them in sync, which renders once with stale data and once more with the fix.
const [items, setItems] = useState(all)
const [visible, setVisible] = useState(all)
// extra render, and a frame where they disagree
useEffect(() => {
setVisible(items.filter((i) => i.done === showDone))
}, [items, showDone])const [items, setItems] = useState(all)
// computed during render, always consistent
const visible = items.filter((i) => i.done === showDone)
// only if the filter is measurably expensive:
// const visible = useMemo(() => ..., [items, showDone])Pitfalls
- Copying server data into a global store
The copy has no idea when the server changes, so it goes stale, and every screen grows its own loading flags and refetch calls. A query cache already handles freshness, deduplication and invalidation; keep server data there and keep the store for state the client truly owns.
- One big Context for everything
Context has no selectors, so every consumer re-renders when any field of the value object changes, and a value built inline is a new object on every render of the provider. Split providers by how often values change, memoize the value, or move frequently changing shared state into a store with selectors.
- Syncing state with useEffect
Copying props into state or deriving one state from another in an effect renders twice and leaves a moment where the copies disagree, which is where flicker and off-by-one bugs come from. Compute the value during render, or reset local state with a
keywhen an identity changes. - Selectors that return new objects
A selector like
s => ({a: s.a, b: s.b})builds a fresh object each time, so the equality check always fails and the component re-renders on every store change, or loops withuseSyncExternalStore. Select primitives separately or use the library's shallow-equality helper. - Keeping URL-worthy state in memory
Filters, tabs and pagination held in
useStatevanish on reload, cannot be shared as a link and ignore the back button, which users experience as the app forgetting what they did. If a value describes what the user is looking at, put it in the URL.
Interview questions
Q1Redux or Context: how do you choose?
I first ask what the state is. Server data goes in a query cache, view parameters in the URL, form and UI state locally, so neither tool is needed for most of it. Context is for injecting rarely changing values like theme or an auth client, because every consumer re-renders on change. For shared client state that changes often I use a store with selectors, Zustand for little ceremony or Redux Toolkit when the team wants an action log and middleware.
Q2Why does updating a Context value re-render components that did not use the changed field?
Because Context has no concept of fields. React compares the provider's value by identity, and if it changed, every component that read that context re-renders. A value object created inline is new on every provider render, so this happens even when nothing meaningful changed. Splitting providers, memoizing the value, or using a store with selectors avoids it.
Q3Walk me through implementing a minimal global store for React.
State lives in a closure with a set of listeners. A setState function replaces the state immutably and notifies all listeners. A hook wraps useSyncExternalStore, passing subscribe and a getter that applies a selector to the current state, so a component re-renders only when its selected value changes. The selector must return primitives or stable references, otherwise the comparison always fails.
Q4What is the difference between server state and client state?
Server state is a copy of data owned elsewhere: it can be stale, it is shared with other users, and it needs fetching, caching and invalidation. Client state exists only in this tab and is always current. They need different tools, and most of the complexity people attribute to global stores came from managing server state as if it were client state.
Q5What happens when a component stores a value derived from props in useState?
It captures the prop once, at mount, and stops following it. When the parent passes a new prop, the component keeps showing the old derived value until something resets it, and patching that with an effect adds an extra render with stale output first. Compute the derived value during render instead, or give the component a key so React remounts it when the identity changes.
Q6What state belongs in the URL?
Anything that describes what the user is looking at and should survive a reload or be shareable: search query, filters, sort, page, selected tab, an open item. The URL becomes the single source of truth and components read it directly. Ephemeral things like hover state or an unsaved draft do not belong there.
Q7How do signals differ from React state?
A signal tracks who reads it and updates only those readers, so a change can touch a single text node without re-running any component. React state re-renders the component that owns it and its subtree, then reconciles the output. Signals make frequent updates cheap and need no memoization, at the cost of a different mental model; React instead relies on reconciliation and, increasingly, its compiler to skip work.
- Classify before choosing: server, URL, form, local UI, shared client or persisted state each have a natural home.
- Server data is a cache problem. A query library or Server Components handle it better than a global store.
- Context is dependency injection with no selectors: every consumer re-renders when the value changes.
- Stores with selectors on useSyncExternalStore re-render only components whose slice changed; signals go further and update single bindings.
- Put shareable view state in the URL and compute derived values during render instead of syncing copies with effects.