Ask a frontend engineer what they spend their time on and the honest answer is deciding where values live. Nearly every bug that makes a UI feel broken — the badge that says 3 when the drawer shows 2, the button that re-enables while the request is still running, the tab that thinks you are still signed in — is the same bug: one fact stored in two places, and the two disagreed.
This post is about picking a home for each piece of state, and about the honest criteria for moving it when the first choice stops working.
The five kinds of state
They are not interchangeable, and most arguments about state libraries are really people talking about different rows of this table:
| Kind | Example | Usually lives |
|---|---|---|
| Local UI | Is this drawer open? What is typed in this box? | In the component. |
| Shared app | Who is signed in. What is in the cart. | Context or a store. |
| Server data | The menu. This customer's orders. | A cache — it is a copy, and it goes stale. |
| URL | Which product page. Which filter. Page 3. | The URL itself. |
| Form | Half-filled fields, which ones are invalid. | The form. |
The two most commonly misplaced are the middle and the fourth. Server data put in a global store gets treated as if it were true forever, when it is a snapshot that was true when it arrived. And state that belongs in the URL — a filter, a tab, a page number — put in a component means the back button breaks and nobody can share a link to what they are looking at.
The escalation ladder
Start at the top and only move down when you have an actual reason. Every step costs indirection.
1. Local state
If one component needs it, it lives there. Most state never leaves this step and that is healthy.
2. Lift it up
When two siblings need the same value, move it to their nearest common parent and pass it down. The demo app does this with the cart drawer's open/closed flag, and says why in the code:
// The drawer's open/closed state lives here because both the navbar (which opens it) and the
// drawer itself need it. This is "lifting state up" — the cart CONTENTS are in context, but
// this piece of purely-visual state is not worth putting there.
const [cartOpen, setCartOpen] = useState(false);Note the distinction being drawn: the cart's contents are shared app state, but "is the drawer showing" is just UI, and shoving it into a global store because it is nearby is how stores become junk drawers.
3. Context
Lifting stops being reasonable when the value has to be threaded through six components that do not care about it — prop drilling. Context lets any descendant read it directly:
The cart is needed by the navbar badge, the menu page, the cart drawer and checkout. Threading it through props would mean passing it through every component in between ("prop drilling"). Context lets any descendant read it directly.
Two things to know before you reach for it:
- Split contexts by how often they change. One giant context means a cart change re-renders everything that only cares about the logged-in user. The demo app keeps auth, menu, cart and toasts separate for exactly that reason.
- Memoise the value you provide. A fresh object literal in the provider is a new reference on every render, so every consumer re-renders regardless of whether anything changed.
4. A reducer
When the updates become a set of named operations rather than "set this value" — especially when several of them depend on the previous state — move the logic into a reducer:
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: { lineId: string } }
| { type: 'SET_QUANTITY'; payload: { lineId: string; quantity: number } }
| { type: 'SET_ORDER_TYPE'; payload: { orderType: OrderType } }
| { type: 'HYDRATE'; payload: CartState }
| { type: 'CLEAR' };This is a discriminated union doing real work. TypeScript narrows the payload from the
type, so the reducer's switch is checked exhaustively — add an action, forget to
handle it, and the build fails rather than the cart quietly not updating.
The other win is testability. A reducer is a pure function: given a state and an action, return the next state. You can test the whole cart's behaviour without rendering anything.
export function cartReducer(state: CartState, action: CartAction): CartState {And the constraint that never goes away — the reducer must return new objects rather than mutating, because reference comparison is what tells the framework to redraw. That is post 4 again.
5. A store
Redux, Zustand, MobX and friends. You have earned one when several screens share a growing amount of state, the updates are genuinely asynchronous, and you need to be able to see the sequence of changes to debug it.
The most useful example in this post
The demo app uses both, and the reason is a better guide than any blog
argument. Its customer-facing half runs on context and useReducer. Its admin half runs
on Redux Toolkit. From src/store/index.ts:
The customer-facing side of this app uses React Context and useReducer, and does so happily: four small, independent, mostly-read contexts (auth, menu, cart, toasts).
The admin side is a different problem. Several screens share a growing amount of state, the updates are genuinely asynchronous, and the thing you most want while debugging a report that disagrees with the orders table is a time-travelling log of every action. That is what Redux is for, and it is why the split runs along that line rather than through the middle of a feature.
Three things worth extracting from that:
- The line runs between problems, not down the middle of a feature. Mixed approaches are fine; a feature half in a store and half in context is not.
- "Mostly-read and rarely changing" is the profile context is good at. Lots of churn shared by lots of consumers is the profile it is bad at.
- The deciding factor was debuggability — being able to replay what happened — not code style.
There is a fourth, and it is the kind of thing that only shows up in a real codebase:
NOTE the store is created here but only PROVIDED inside AdminLayout, which is a lazy route. Redux therefore ships in the admin chunk and costs the 99% of visitors who never open /admin exactly nothing. Putting <Provider> in main.tsx would have pulled it into the entry bundle.
Where you mount a provider is a bundle-size decision as well as an architectural one. Post 10.
Server data is not really state
The biggest shift in frontend practice over the last few years: data fetched from an API is a cache, not state. It arrived at a moment, it can be stale, someone else may have changed it, and two components asking for it should not produce two requests.
Once you see it that way, the list of things you need is not "a store" but: deduplication, caching, revalidation, and a shared loading and error state. Libraries like TanStack Query exist to supply exactly that list, and they remove most of what people used to put in Redux.
The demo app does it by hand once, deliberately, and points at what that costs:
The menu, toppings and crusts are needed by the menu page, the builder modal and the admin screen. They change rarely and are identical for every visitor, so fetching them once here beats each component fetching for itself — three components mounting would otherwise mean three identical round trips.
This is where a data library like TanStack Query would normally go. Doing it by hand once is worth seeing first: the loading flag, the error branch, and the cleanup are exactly what such a library gives you for free.
Do it by hand once. Then use the library.
The URL is state too
If a value should survive a refresh, be shareable as a link, and work with the back button, it belongs in the URL — not in a component. Which product, which tab, which filter, which page. Getting this wrong is the difference between an app that feels like a website and one that feels like it is fighting the browser. Post 8.
Questions that decide it
| Ask | If yes |
|---|---|
| Can I compute this from state I already have? | Do that. Do not store it — derived state that is stored is state that can disagree. |
| Should it survive a refresh or be shareable? | The URL, or storage. |
| Did it come from the server? | Treat it as a cache. Reach for a data library. |
| Does exactly one component use it? | Keep it local. |
| Do two siblings use it? | Lift it to the parent. |
| Is it threaded through components that do not care? | Context. |
| Are updates a set of named operations on previous state? | A reducer. |
| Do many screens share it, change it asynchronously, and need debugging? | A store. |
The mistakes that cost the most
- Storing derived values. Keeping
itemsanditemCountguarantees a moment where they disagree. Compute the count. - Reaching for a global store on day one. You will put everything in it, including the drawer flag.
- One enormous context. Every change re-renders everything.
- Duplicating server data into a store and editing the copy. Now there are two truths and the server's is the one that counts.
- Mutating instead of replacing. The data updates and the screen does not.
The one thing to take from this post
Every value should have exactly one home, and you should be able to say what it is. Start local, escalate only when something concrete forces you to, and remember that data from the server is a cache with an expiry date rather than a fact. Get that right and a whole category of "the UI is out of sync" bugs simply never happens.
Next: Talking to the Backend.