EngineerGENERALSTAGE-SPECIFICILLUSTRATIVE

The UI Holds a Copy

The cart moved to the server; the page still has to show it. The UI keeps a copy in component state, calls the API on every button, and replaces the copy with the response — the server's cart is the truth and the UI's is a cache. Optimistic updates come later, with a rollback rule.

The moveWorked exampleNext questions

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

The cart lives on the server and the page has an Add button. What does the button do, what does the page hold, and which of the two carts is the real one?

The situation

You have POST /cart/items working from a REST client. Now the product page needs an Add button and the header needs an item count. You put a cart array in component state, push into it on click, and also call the API. It works in the demo and the count is wrong after a reload, after a rejected add, and whenever the second tab does anything.

The reflex

Update the local state as if the function were local. The button pushes into the array, re-renders, and fires the request on the side. It is what the in-memory cart did, it feels instant, and the request's response is ignored because the UI already knows the answer.

Why it stalls

There are two carts and the code does not know which is real. The local array says [Laptop × 1]; the server said 400 unknown product; the header shows 1 until a reload shows 0. The truth was on the server the whole time and nothing read it.

What the reflex produces — and fails to produce
  • There are two carts and the code does not know which is real. The local array says [Laptop × 1]; the server said 400 unknown product; the header shows 1 until a reload shows 0. The truth was on the server the whole time and nothing read it.
  • The local push reimplements addItem badly. The component appends without the find, so two clicks show two laptop rows while the server holds one row with quantity 2 — the record's predict-the-bug case, reappearing in the UI because the logic was copied instead of called.
  • Every screen that shows the cart has its own copy. The product page, the header badge and the cart drawer each hold an array, each updates on its own events, and they agree only after a full reload.
  • "Optimistic" was chosen without its rollback. The UI shows the add instantly and never undoes it when the server says no, so the shopper checks out a cart the server never had (Optimistic UI is a rule pair, not a mood).
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Decide which cart is the truth, and it is the one the server saves. On the server-side rungs, the UI's cart is a cache of a response: it exists so the page can render without a request on every frame, and it is only ever correct as of the last response. Name it that way in the code — cartFromServer, not cart.
  • Make every button a request whose response replaces the copy. Click Add → POST /cart/items → the response is the cart → set state to the response. The UI never computes the new cart itself; it displays what the server returned. The rules live in the shared function and the endpoint, not in the click handler (Where Should This Code Live?).
  • Hold one copy, in one place, and derive the rest. The badge count, the drawer contents and the running total are all functions of the one copy; nothing else stores cart data. Names and prices are looked up from the catalog as the record says — the copy holds product ids and quantities, exactly what the API returns.
  • Add optimism only when the delay is felt, and add it with its rule: show the expected result immediately, and if the response is a rejection, put the copy back to what it was and say why. The record's frontend note states the order: replace-with-response first; optimistic updates later, "only with the rule that a rejected add rolls the UI back".

One click, five stops

The trace follows an Add click through the page, the network and back, on the database rung. Two of the five stops happen on the server; the page's only job is to send the input and display the output. Notice what the page does not do: it does not look for an existing entry, and it does not decide which branch is taken.

Click Add (laptop) on the product page
  1. inputThe component sends POST /cart/items { productId: 'laptop', quantity: 1 }; the local copy is { items: [{ laptop, 1 }], total: 1000 } from an earlier response; the button is disabled while the request is in flight.
  2. lookupThe server loads the owner's cart rows and calls addItem, which finds the existing laptop entry — this lookup happens where the truth is, not in the page.
  3. branchThe existing entry is found, so the "increase" branch runs; the page did not know it would, because it did not need to.
  4. mutationThe server's cart becomes [ Laptop × 2 ]; the row is updated; the response body is the whole cart with total 2000.
  5. outputsetCart(response). The page now shows Laptop × 2 and a badge of 2, derived from the copy; the button is enabled again. Had the response been 400, the copy would be unchanged and the rule shown.

The slice that proves the button works

The first frontend integration is a slice, not a feature: one button, one request, one replaced copy, through every layer. What it proves and what it leaves open are both worth writing down before it is declared done.

Add to cart, end to end
Clicking Add on a product page shows the updated cart from the server.
  1. PageHolds the last GET /cart response; on click sends POST /cart/items and calls setCart with the response; shows loading and the rejection message.
  2. APIParses the body, re-checks the rules, calls the shared addItem, saves, responds with the cart and its total.
  3. Cart logicThe unchanged addItem: catalog check, positive quantity, find-then-increase-or-append.
  4. Databasecart_item rows with the unique constraint; the add is an upsert on (cart_id, product_id).
proves
The page can change the server's cart and display exactly what the server holds; a rejection leaves the page honest.
does not prove
That two tabs stay in step, that the page feels fast on a slow link, that the catalog lookup for names and prices scales, or that the merge at login works — each is its own slice.

Pessimistic first, optimistic with a rule

The comparison is not "slow versus fast"; it is "always right versus usually faster and right only with the rollback". The second form is better when the delay is felt and the rollback is written; the first is better everywhere else, and it is where the page starts.

Two shapes of the click handler
Local push, request on the side
onAdd(productId):
    cart.items.push({ productId, quantity: 1 })   -- reimplements addItem, wrongly
    render()
    fetch POST /cart/items                        -- response ignored
Replace with the response; optimism only with rollback
onAdd(productId):
    previous = cart
    cart = preview(addItem(copy of cart, productId, 1))   -- optional, shared function, display only
    response = await POST /cart/items { productId, quantity: 1 }
    if response.ok: cart = response.body
    else:           cart = previous; show(response.body.rule)

The second form has one source of truth — the response — and a defined path back when the server disagrees. The first has two carts that agree by luck, and no code that ever reads the server's answer, so a rejection cannot be shown because it was never noticed.

The implementation ladder

Concept, examples, pseudocode, code, tests, production — for the concept this lesson is about. Code is the fourth tab, not the first.

concept Shopping Cart beginner
Build it step by step →

Shopping Cart = A temporary collection of products the user intends to purchase, held between browsing and checkout.

Identity, ownership, lifetime
  • Does a cart have identity? Yes, weakly. Two carts with the same items are still two carts, because each belongs to someone and will become a different order. It needs an id once it leaves memory; in memory the variable is the identity.
  • Who owns it? A shopper — a logged-in user or an anonymous session. The owner is part of the state because "my cart" has to be findable again.
  • How long does it exist? From the first add until checkout or abandonment. Whether it survives a reload, a closed browser or a login is not a property of the concept; it is a persistence decision made later, and each answer changes where the cart lives.
  • Should it survive reload? Usually yes for a store, usually no for a demo. V1 in memory says no; V2 browser storage says yes on one device; V3 server storage says yes everywhere the user is logged in.
  • Should it survive login? Only if an anonymous cart and a logged-in cart are merged — a rule that does not exist in V1 and appears as a modification later.
State it must remember
  • itemscollection of CartItemkeepThe cart is its items; without them nothing else means anything.
  • items[].productIdidkeepThe reference to what is being bought. The catalog owns the product; the cart only points at it.
  • items[].quantityinteger > 0keepTwo laptops is one entry with quantity 2, not two entries — the rule "one entry per product" needs a quantity to hold.
  • owneruser id or session iddependsSo the cart can be found again by the person it belongs to.
  • items[].productNamestringderiveIt would be convenient to render the cart without a catalog lookup.
  • items[].pricemoneydependsThe total needs a price per item.
  • totalmoneydropEvery screen shows the total.
  • currencycodedependsPrices need a currency to be added.
  • createdAttimestampdropAbandoned carts might be expired or emailed about.
Operations
  • update Add item the updated cart
  • delete Remove item the updated cart
  • update Change quantity the updated cart
  • read View items the list of entries — product id and quantity — for rendering
  • domain Calculate total the sum of price × quantity over the entries
  • delete Clear cart the empty cart
Rules that must always hold
  • Every quantity is greater than zero.
  • One logical entry per product.
  • The total is never negative.
  • An unknown product cannot be added.
  • Quantity cannot exceed available stock — if inventory is enforced here.

How to do it

Most important first.

  • Name the state by what it is: the last response from GET /cart. Load it on mount; every mutation replaces it with the mutation's response (Server State Is Not Your State).
  • Write the click handler as request → replace, with no local mutation of the cart. If you feel the need to compute the result locally, that is the shared function's job and it can be called for a preview — never for the stored copy.
  • Derive everything shown from the one copy: count = sum of quantities; total = the server's total field, or the shared total function over catalog prices for a preview (Derived State).
  • Cover the three states every request has — loading, success, rejected — before covering optimism. A disabled button during the request is the honest first version.
  • When adding optimism, write the rollback in the same commit as the optimistic update, and test it by making the endpoint reject.

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • The Add button, done as replace-with-response. State: cart = the last GET /cart response, initially { items: [], total: 0 }. Click: POST /cart/items { laptop, 1 } → 200 { items: [{ laptop, 1 }], total: 1000 } → setCart(response). The header badge reads items.reduce(sum of quantity) = 1. The component never called addItem.
  • A rejected add. Click with a product the catalog has since removed: POST → 400 "unknown product". Replace-with-response: state unchanged, an error shown from the body. Under the reflex the local array had already been pushed to; under this move nothing was, and there is nothing to roll back.
  • Two clicks on Add. Two POSTs; the second response is { items: [{ laptop, 2 }] }; setCart twice; the page shows Laptop × 2 in one row. The find-before-append happened on the server; the UI showed the truth twice.
  • The second tab. Tab A adds a mouse; tab B still shows the cart without it until its next request. Under replace-with-response, tab B's next click returns a cart containing the mouse, and it appears — later than ideal, but never wrong. Keeping tabs in step in real time is a later requirement (State Synchronization); showing a stale copy that is honest about being a cache is the correct first version.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • The component state is named as a response, and the only writes to it are setCart(response).
  • No click handler contains cart logic; addItem is called by the endpoint, not the button.
  • One copy exists; the badge, drawer and total are derived from it, and there is nothing to keep in sync.
  • A rejected request leaves the page showing what the server holds, with the rule that rejected it.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?Which cart is the truth on this rung, and is the UI's copy named as a cache of it?
  • ?Does any click handler compute the next state instead of asking for it?
  • ?What does the page show while the request is in flight, and after it is rejected?
  • ?Is the delay between click and response felt — and if I hide it optimistically, what is the rollback?

What can go wrong

How the move itself fails
  • Replace-with-response is applied to state that is genuinely local. The "drawer open" flag is stored on the server because the cart is; the request round trip for a toggle is felt on every click. Not all state is server state (The Seven Kinds of State).
  • The copy is treated as truth in the checkout page. The order is created from the UI's cart instead of the server's; a stale copy produces an order for items the server had already removed (Source of Truth).
  • Optimism is added everywhere because it was added once. Removing an item optimistically, then failing, then rolling back a list the shopper has since scrolled — each optimistic path is its own rollback to test, and most buttons never needed one.
  • The one-copy rule becomes a global store for everything. The cart lives beside the catalog, the session and the UI theme in one object, and a change to any of them re-renders the cart; one copy of the cart does not mean one object for the app.
What the move costs
  • Replace-with-response means every cart change waits for a round trip before it shows; on a slow connection the button feels dead, which is what eventually justifies optimism.
  • Holding one copy means every component that shows the cart depends on wherever that copy lives, which couples pages that were independent.
  • Naming the copy a cache invites the next question — when is it stale? — which is more work than pretending it is the cart.
Misreads
  • "Optimistic UI is just the modern default." It is a pair: the immediate update and the rollback on rejection. Without the second half it is a UI that lies, and the record puts it after replace-with-response for that reason.
  • "Calling the shared addItem in the click handler keeps things in sync." It computes what the server will probably say; only the response says what it did say. Use the shared function for a preview if you must, and still replace with the response.
  • "The UI copy is the cart on the browser-storage rung too." On that rung it is the cart — the browser is the truth. The move here is specific to the server-side rungs, which is why The Persistence Ladder comes first.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERALA UI holding a cache of a server resource and replacing it with each response is the shape for any server-owned concept — a conversation's messages, a todo list, a job's status.
  • STAGE-SPECIFICOn the browser-storage rung the UI's cart is the truth and this lesson does not apply; it starts to apply the moment the cart is saved somewhere the browser does not own.
  • ILLUSTRATIVEThe laptop, the removed product and the two tabs render the concept record's frontend notes; no real page or framework is described.

Where the depth lives

This domain asks the question and hands the answer off by name.