Skip to content
Episode 16 · The basket that remembered the wrong dates

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.

30 min intermediateLocal vs shared vs server vs URL stateServer state as a cache (query libraries)Keep state as close as possible to where it's usedForm state and client vs server validationNever trust the clientWhat the frontend is responsible for
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

01

The problem

2 min
Wednesday, 16:30. Maya has a support report.
  1. Maya · Product manager
    23 tickets this week. A guest moved her stay from 12–14 to 19–22 May. Basket still says €280, not €420.
  2. Maya · Product manager
    And links shared on WhatsApp open an empty search page.
  3. You · Engineer at StayHub
    Everything's in one store, searchStore. Two separate bugs?
  4. Lina · Senior engineer, your mentor
    You tell me. Guess first.
02

The big idea

4 min
Five kinds of state
The big idea

Classify 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.

03

How it works

5 min

State is any data that can change while the app runs and changes what the guest sees. The search page holds five kinds:

Five kinds of state on the search page
  1. 1
    Local UI

    Is the Amenities dropdown open? Only one component cares: useState. See .

  2. 2
    Shared client

    Currency in the header changes prices in the list, map and basket: a small .

  3. 3
    URL

    City, dates, guests, sort: , kept in the address so reload, Back and shared links reproduce it.

  4. 4
    Form

    What the guest types at checkout, and which fields are invalid. Validated again on the server.

  5. 5
    Server

    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.

04

Watch a decision being made

4 min
Worked example

You and Lina untangle searchStore

Fix the stale total and the empty shared links, and stop /api/hotels firing four times per load.

  1. 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.

  2. 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.

    tsx
    const 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.

  3. 3Compute the total, don't store it

    const total = nights(from, to) * rate during render: 3 × €140 = €420.

    Why: A derived value can't drift from its inputs.

  4. 4Keep the store small

    Left in the store: currency. amenitiesOpen goes back to useState.

    Why: Every subscriber of a big store re-renders on changes it doesn't care about.

The whole thing in one line: Filters in the URL, hotels in a query cache, the dropdown local, currency in a tiny store, the total computed. Both bugs disappear and /api/hotels fires once.
05

In real life

3 min
How much state goes in the URL

Airbnb 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.

06

What goes wrong

3 min
Copying API responses into a global store

You hand-write loading flags, deduplication and invalidation; stale data follows.

Instead

A server-state cache keyed by request.

Making everything global "just in case"

Everything re-renders on unrelated changes, and anything can change anything.

Instead

Start local; lift only as far as needed.

Storing derived values

total, nights and rate stored separately can disagree.

Instead

Compute derived values during render.

07

Trade-offs

3 min
HomeFitsYou payMain risk
Local stateOne component cares (the default)NothingHard to share far away
URLFilters, pagination, tabsParsing stringsPersonal data in links
Global storeCurrency, multi-step basketCouplingBecomes a dumping ground
Server-state cacheData others can changeA dependency, staleness per queryForgetting to invalidate
Friday, 10:00. Planning next quarter's frontend work.
  1. Karim · CTO
    Standardise: everything in one Redux store. One pattern every new hire knows.
  2. Lina · Senior engineer, your mentor
    We'd be hand-writing caching again. URL, a query cache and a tiny store.
  3. Karim · CTO
    That's three tools to learn instead of one, and a new dependency.
08

Your job as tech lead

4 min

Claude 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.

Decisions you own
  • 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.
Tell Claude Code
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.
Check before you accept
  • 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 total or price from the browser.
Red flags
  • 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.
09

Check yourself

2 min
Q1/3Reasoning
Match each piece of data to its most natural home:
Is the date-picker popup open?
City, dates and sort order of a search
The guest's upcoming bookings
Selected display currency used across the app
How confident are you?

Next 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.

Finished?

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.
10

Technical deep dive

Server data as a cache
Worked example

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.

  1. 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.

  2. 2Ask who owns each fact

    Server owns hotels and prices. The guest's search owns city, from, to, guests, sort. One component owns amenitiesOpen. The app owns currency. Nobody should own basketTotal: it's nights × rate.

    Why: The owner decides the home. Two owners for one fact is how drift starts.

  3. 3Move filters to the URL

    Read filters from the query string, and change them by navigating.

    tsx
    const 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.

  4. 4Put server data in a query cache

    Fetch hotels with a query keyed by the filters, and invalidate after a booking.

    tsx
    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), and invalidation stops the "1 room left" lie after a booking.

  5. 5Compute the total, don't store it

    const total = nights(from, to) * rate during 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.

  6. 6Keep the store small

    What's left in the store: currency. amenitiesOpen goes back to useState in 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.

The whole thing in one line: You listed every piece of state, asked who owns it, and gave each fact one home: filters in the URL, hotels in a query cache, the dropdown local, currency in a tiny store, and the total computed. Both bugs disappear and /api/hotels fires once.
Kind of stateExampleNatural homeLifetime
Local UIDropdown open, input focususeState / useReducerWhile the component is mounted
Shared clientCurrency, theme, multi-step basketLifted state, context, Zustand/ReduxSession
URLCity, dates, sort, pagePath + query stringReload, share, history
FormField values, touched, errorsForm library or local state + schemaUntil submit/cancel
ServerHotels, prices, availability, bookingsQuery cache (TanStack Query, SWR)Until stale; truth is the server
Classify first, then pick the tool.
11

Interactive simulation

12

Visual diagram

Click a component

Where each kind of state lives on a hotel search page. Start the walkthrough.

13

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.

14

Check your understanding

Q1/3Debugging
Typing in the hotel search box feels laggy. The input's value is stored in a global context that also provides user, currency and basket to ~200 components. Why, and what do you change?
How confident are you?
15

Practice ladder

Step 1 · Trace

Trace a date change

Scenario Warm-up

After 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.

Step 2 · Complete

Finish the state map for the hotel page

Design Practice

You'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.

StateHome
Photo gallery: which photo is shownlocal 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___
Step 3 · Solve

Typing in the search box lags

Debugging Practice

Maya: "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.

Step 4 · Decide

Remember the last search?

Decision Stretch

Maya: "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.

16

Explain it yourself

A junior developer asks: "Should we just put everything in Redux so it's all in one place?" Explain your answer.

0 words