Skip to content
AtomicReps

Props, state, and identity

A lesson from React Under the Hood. Play it above, or read it through below.

  • State lives in React, filed under a position

    The common read: my component owns its state, so what identifies that state is the component itself, and React hands it back whenever that component turns up again

    Something keeps handing your component its state back, render after render. Where does it live, and what is it filed under? Not in your component: your function does not own its state. React holds it and files each piece under the position you already have from lesson 1, so retention is a function of that address, the component type sitting there, and time. Nothing else.

    Replace every prop a component has in one render, and the state it was holding is

    Takeaway: React runs a coat check: it keeps the coat, and the ticket is the position, not your name.

    React discards what a position was holding the moment nothing renders there anymore, immediately and with no stash to recover it from. Two documented ways carry a value past that point anyway, by refusing to let the position disappear at all:

    leave the node mounted and hide it with CSS lift the value into the parent

    That is why the review panel still shows the last candidate's rating after you switch candidates: the fetch returned correctly, the component just never lost its position. Same type, same position, same stored state, and the new props arrive at a component that never lost anything.

  • Props are the arguments of one render

    The common read: props are a live channel into the parent's data, so writing a field on the object in my hand is how a child pushes a change back up

    React calls your component and hands it exactly one thing. What is that thing, and how long does what it carries stay true? A component function takes exactly one argument: a props object belonging to one call only. The next render is a different call with a different object, the parent decides what goes in it. Write a field on the object you were handed and a development build throws: React froze it shallowly before your function ever saw it, the same freeze lesson 1 showed on the element.

    Go one level down and write a field on an object a prop is carrying. That write is

    Takeaway: Shallowly is the load-bearing word: the object itself is frozen, nothing inside it is copied.

    Reach one level in and write a field on an object a prop carries, and nothing stops you: that object is the parent's own, so the field is wrong at the source. The fix is to ask the parent for a new object, not edit the one you were handed.

    Copying a prop into useState makes the same drift with no freeze to catch it: a value correct once, now tracking nothing, filed against a billing service that returned the new plan while the header still shows the old name.

  • A state variable is a constant of its render

    The common read: the state variable is my live handle on the value React is holding, so a write lands on it and the new value is there a moment later, and I can await or defer my way to reading it back

    Move that same assignment into a click handler instead of the render body. Does the screen still update? React calls your function once per render and hands it that render's state values, and those values are fixed for as long as any code belonging to that render can still run, an await three seconds later included: you edited your copy, React's stored value never heard about it, and the next render re-reads what React holds.

    The honest name for this bug is

    The off-by-one shows the cost: a handler bumps an attempt count, then checks that same count to decide whether to keep trying. It reads the number from before the click, so it gives up one click later than the person who wrote it believes, every time, forever. Put an ordinary mutable box next to the state variable, write it and read it back in the same tick, and it hands back exactly what you wrote, the way every other variable does. The one next to it does not.

    Takeaway: That is why the bug arrives as a log line, not a crash, and gets filed against the setter, the one line in the handler that was behaving correctly.

  • Every render mints its own handlers

    The common read: a component has one set of variables that lives as long as the component does, so a callback I registered earlier reads whatever state is current when it finally fires

    A timer is holding a callback you registered three renders ago. When it finally fires, which render's values does it read? Handlers do not survive a re-render.

    The screen has re-rendered twice while a save sat in flight. When the awaited handler resumes and reads the draft, it gets

    Takeaway: Every render builds its own functions over its own values. The callback a timer holds, the continuation behind an await, the loop body of a for three commits deep: all closures over the render that made them, never re-pointed when a later render commits.

    This is the half of the snapshot that costs money, not confusion. An in-flight handler reading a cancellation flag, a quota, a recipient or a draft reads the value true when the user acted, not the value true when the write lands, so the request goes out against stale input and writes something wrong rather than nothing.

    It arrives as "the app saved the previous document's title", filed against whatever queue performed the write, weeks after a pull request added an await to a handler that used to be synchronous.

  • The queue holds operations, not values

    The common read: the queue is a list of values, so whatever was queued last is what lands

    You just watched the left button lose an increment. What does a setter call actually do, if not assign? It appends one entry to a queue React drains during the next render, once your handler has finished running. That is where batching comes from and where it stops: three setters in one handler cost one render, and two separate clicks are two queues that never merge.

    An updater function sits in that queue behind another entry. When React drains it, the updater receives

    Takeaway: An updater receives the previous entry's output, so updaters compose. A plain value appends "replace with X" and discards everything already queued: setState(5) is setState(n => 5) with the argument unused.

    Both buttons read correctly out loud, both pass a click-once test, and the one that under-counts does it silently, only under the load of a second click. That is why it survives review and reaches production, then arrives as "the unread badge is off by a few by lunchtime", filed against a socket that delivered every message correctly. Keep updaters pure and return the result: StrictMode runs each one twice and quietly throws the second result away, which is its way of asking if you meant that.

  • An Effect that sets state buys a render, a commit, and another render

    The common read: a derived value kept in state and synced by an Effect is a wiring problem, so getting the sync right makes the pattern correct

    A derived value sits in state, and an Effect keeps it synced. Which half of that is the mistake?

    The wiring is not

    Takeaway: React calls your components, commits the result to the DOM, then runs your Effects. An Effect that immediately sets state restarts that whole pipeline: one pass traded for four phases, for a value the render body could have computed for free.

    Where a value genuinely cannot be computed, adjusting state during render is the sanctioned last resort:

    render returns, and React discards the JSX the component retries immediately, before any child renders children still render exactly once

    useMemo is the escalation for when the computation itself is slow, a later problem, not this one. The version that reaches a pager is a chain: a filter sets a count, the count sets a page number, the page number sets a fetch flag, and the tree renders four times per keystroke, filed as a performance problem against the list.