State management: where should this data live?
Wednesday, 16:30: 23 support tickets this week about the search page. A guest changed her dates and the basket still says €280 instead of €420, and shared search links open an empty page.
Storing data in a frontend is easy. Deciding who owns each piece, and how long it lives, is the real skill. Here's a simple way to sort it.
- Problem
A hotel search page shows filters, results, a map, a favourites heart and a booking form. Several components need the same data, and some of it changes on the server while the page is open.
- Why the simple system fails
Putting everything in one global store means hand-written loading flags, duplicated fetches, stale prices after a booking, filters lost on reload, and a re-render of the whole page on every keystroke.
- Engineering concept
Classify state by owner and lifetime: local UI state, shared client state, URL state, form state and server state. Each kind has a natural home, and server state is a cache of someone else's truth.
- Technology
React useState/useReducer, lifting state and context, URL search params, Zustand or Redux for shared client state, TanStack Query/SWR for server state, form libraries with schema validation.
- Trade-offs
Local state is simple but hard to share; global stores share easily but couple everything; query caches remove boilerplate but add a dependency and freshness decisions; URL state is shareable but public and string-typed.
The problem
2 min- Maya · Product manager23 tickets this week. A guest moved her stay from 12–14 to 19–22 May. Basket still says €280, not €420.
- Maya · Product managerAnd links shared on WhatsApp open an empty search page.
- You · Engineer at StayHubEverything's in one store,
searchStore. Two separate bugs? - Lina · Senior engineer, your mentorYou tell me. Guess first.
The big idea
4 minClassify the state, then give each fact exactly one home.
The question isn't which library. It's who owns this fact, and how long it should live. One store for everything mixes five lifetimes and owners.
How it works
5 minState is any data that can change while the app runs and changes what the guest sees. The search page holds five kinds:
- 1Local UI
Is the Amenities dropdown open? Only one component cares:
useState. See . - 2Shared client
Currency in the header changes prices in the list, map and basket: a small .
- 3URL
City, dates, guests, sort: , kept in the address so reload, Back and shared links reproduce it.
- 4Form
What the guest types at checkout, and which fields are invalid. Validated again on the server.
- 5Server
Hotels, prices, availability: server state, truth on the server. The browser's copy is a that goes stale and must be refetched or invalidated.
And the basket total? None of these. It's derived: nights × €140, computed at render. Stored separately, the date change never touched it: 2 × €140 = €280 instead of 3 × €140 = €420. The rule: one owner per fact. Dates in the URL, the rate on the server, the total computed from both.
Watch a decision being made
4 minYou and Lina untangle searchStore
Fix the stale total and the empty shared links, and stop /api/hotels firing four times per load.
- 1List every piece, ask who owns it
city,from,to,guests,sort: the guest's search.hotels[]: the server.amenitiesOpen: one component.currency: the app.basketTotal: nobody, it's nights × rate.Why: The owner decides the home. Two owners for one fact is how drift starts.
- 2Filters to the URL, server data to a query cache
Read filters from the query string; key the hotels query by them; invalidate after a booking.
tsxconst params = useSearchParams(); const filters = { city: params.get("city"), from: params.get("from"), to: params.get("to") }; useQuery({ queryKey: ["hotels", filters], queryFn: () => getHotels(filters), staleTime: 30_000 }); useMutation({ mutationFn: bookRoom, onSuccess: () => qc.invalidateQueries({ queryKey: ["hotels"] }) });Why: Same key means one shared request (4 → 1). Invalidation stops "1 room left" lying after a booking.
- 3Compute the total, don't store it
const total = nights(from, to) * rateduring render: 3 × €140 = €420.Why: A derived value can't drift from its inputs.
- 4Keep the store small
Left in the store:
currency.amenitiesOpengoes back touseState.Why: Every subscriber of a big store re-renders on changes it doesn't care about.
In real life
3 minAirbnb search: the whole search in the URL
- A search link visibly carries place, dates and guests
- Sharing, reload and Back reproduce the same search
- Must be parsed, and never hold personal data
Fits pages people share and navigate back and forth.
Google Docs: only the document id
- The content is server state, kept in sync live
- Cursor and menus stay in the app; a link shows the latest version
Fits a shared object that changes all the time.
What goes wrong
3 minYou hand-write loading flags, deduplication and invalidation; stale data follows.
A server-state cache keyed by request.
Everything re-renders on unrelated changes, and anything can change anything.
Start local; lift only as far as needed.
total, nights and rate stored separately can disagree.
Compute derived values during render.
Trade-offs
3 min| Home | Fits | You pay | Main risk |
|---|---|---|---|
| Local state | One component cares (the default) | Nothing | Hard to share far away |
| URL | Filters, pagination, tabs | Parsing strings | Personal data in links |
| Global store | Currency, multi-step basket | Coupling | Becomes a dumping ground |
| Server-state cache | Data others can change | A dependency, staleness per query | Forgetting to invalidate |
- Karim · CTOStandardise: everything in one Redux store. One pattern every new hire knows.
- Lina · Senior engineer, your mentorWe'd be hand-writing caching again. URL, a query cache and a tiny store.
- Karim · CTOThat's three tools to learn instead of one, and a new dependency.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Where each piece of data lives: filters in the URL, hotels in a query cache, currency in a small store, a dropdown flag in its component.
- Which values the browser only displays and never sends as truth: the server computes €140 × 3 nights = €420.
- Whether to refactor the whole store now, or move one kind of state at a time. Everything at once is a big risk for 6 engineers.
StayHub's search page keeps filters, hotel results, the basket total and UI flags in one store (searchStore). Changing dates from 12–14 to 19–22 May at Hotel Alfama (€140/night) still showed €280 instead of €420, and /api/hotels fires 4 times on load. Move filters and dates into the URL, hotel results into a query cache keyed by them. Checkout sends only hotel, room and dates; the server computes the total. Don't touch hotel pages. Plan first, then show request count on load and a date-change test.
- The network tab shows /api/hotels once on load, not 4 times.
- Change dates, then reload or share the link: the same search comes back.
- The checkout request body has no
totalor price from the browser.
- Hotel results kept both in the store and in the query cache.
- The total sent to checkout "because it's already computed there".
- Personal data (passport number, email) in the URL.
Check yourself
2 minNext episode: the hotel page is now correct everywhere, but on a mid-range phone it scores 38 out of 100 and takes 4.2 s to show its main photo.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You and Lina untangle searchStore
The search page keeps filters, hotel results, the basket total, UI flags and currency in one global store. Fix the stale total and the empty shared links, and stop /api/hotels firing four times per load.
- 1List every piece of state
Write down what the store holds:
city,from,to,guests,sort,hotels[],basketTotal,amenitiesOpen,currency.Why: You can't place what you haven't named, and the list usually reveals a copy or two.
- 2Ask who owns each fact
Server owns
hotelsand prices. The guest's search ownscity,from,to,guests,sort. One component ownsamenitiesOpen. The app ownscurrency. Nobody should ownbasketTotal: it's nights × rate.Why: The owner decides the home. Two owners for one fact is how drift starts.
- 3Move filters to the URL
Read filters from the query string, and change them by navigating.
tsxconst params = useSearchParams(); const filters = { city: params.get("city") ?? "lisbon", from: params.get("from"), to: params.get("to"), guests: Number(params.get("guests") ?? 2), }; // Changing dates = router.push("?city=lisbon&from=2026-05-19&to=2026-05-22&guests=2")Why: Shared links, reload and Back all start from the URL, so all three are fixed by one move.
- 4Put server data in a query cache
Fetch hotels with a query keyed by the filters, and invalidate after a booking.
tsxuseQuery({ queryKey: ["hotels", filters], queryFn: () => getHotels(filters), staleTime: 30_000 }); useMutation({ mutationFn: bookRoom, onSuccess: () => qc.invalidateQueries({ queryKey: ["hotels"] }) });Why: Same key means one shared request (4 → 1), and invalidation stops the "1 room left" lie after a booking.
- 5Compute the total, don't store it
const total = nights(from, to) * rateduring render. 3 × €140 = €420 for 19–22 May.Why: A derived value can't drift from its inputs. It's always right because it's never saved.
- 6Keep the store small
What's left in the store:
currency.amenitiesOpengoes back touseStatein the dropdown.Why: Every subscriber of a big store re-renders on changes it doesn't care about. A small store is easier to reason about.
| Kind of state | Example | Natural home | Lifetime |
|---|---|---|---|
| Local UI | Dropdown open, input focus | useState / useReducer | While the component is mounted |
| Shared client | Currency, theme, multi-step basket | Lifted state, context, Zustand/Redux | Session |
| URL | City, dates, sort, page | Path + query string | Reload, share, history |
| Form | Field values, touched, errors | Form library or local state + schema | Until submit/cancel |
| Server | Hotels, prices, availability, bookings | Query cache (TanStack Query, SWR) | Until stale; truth is the server |
Interactive simulation
Visual diagram
Where each kind of state lives on a hotel search page. Start the walkthrough.
Analogy
- At hotel receptionA sticky note on one screenIn a frontendLocal component state
- At hotel receptionThe whiteboard: "today's currency: EUR"In a frontendShared client state
- At hotel receptionThe reference number on the guest's slipIn a frontendState in the URL
- At hotel receptionA half-filled registration cardIn a frontendForm state
- At hotel receptionA screen showing the central booking systemIn a frontendA cached copy of server data
Where the analogy breaks: a receptionist knows to refresh their screen. Code doesn't, unless you tell it when the copy is stale and when to fetch again.
Check your understanding
Practice ladder
Trace a date change
Scenario Warm-upAfter the refactor, the search page works like this:
- Filters come from the URL:
?city=lisbon&from=2026-05-12&to=2026-05-14&guests=2 - Hotels come from
useQuery({ queryKey: ["hotels", filters], staleTime: 30_000 }) - The basket total is computed:
nights(from, to) * rate - Currency (EUR) is in a small store.
The guest changes the dates to 19–22 May at 10:00:00, then presses Back at 10:00:20.
Trace what happens at each step: what changes in the URL, which requests fire, and what the basket shows.
Finish the state map for the hotel page
Design PracticeYou're placing every piece of state on the hotel page. Fill in the home for the four rows marked ___, and say why in one line each.
| State | Home |
|---|---|
| Photo gallery: which photo is shown | local state |
| Selected room type ("Double, sea view") | ___ |
| Live price for the selected room | ___ |
| "Add to favourites" heart, saved to the guest's account | ___ |
| Total for the stay | ___ |
Typing in the search box lags
Debugging PracticeMaya: "Typing a city in the search box feels laggy on my phone." Omar profiles it: each keystroke re-renders the whole search page: the 200 result cards, the map and the header.
The code: the text the guest is typing is saved into the global store (searchStore.setCityInput(value)) on every keystroke, and almost every component reads searchStore with useSearchStore() (the whole store, no selector).
Explain the cause and propose a fix.
Remember the last search?
Decision StretchMaya: "When a returning guest opens StayHub, pre-fill their last search: city, dates, guests." About 40% of guests use more than one device (phone and laptop).
Option A: save the last search in the browser's localStorage. Option B: save it on the server, in the guest's account (only for logged-in guests).
Write a short decision: "We chose X over Y because…, accepting the risk of…", and say where the search lives once it's pre-filled.
Explain it yourself
A junior developer asks: "Should we just put everything in Redux so it's all in one place?" Explain your answer.