Skip to content
AtomicReps

Props, state, and identity

A lesson from How React Works. Play it above, or read it through below.

Level I · The Rendering Model
  • React stores state and identifies it by position

    The common read: my component owns its state, so what identifies that state is the component itself, and React returns it whenever that component renders again

    Your component receives its state again on every render, and your function does not store that state. React holds it and stores each piece under the position you know from lesson 1, so whether React keeps the state depends only on that position, the component type rendered at it, and how long something stays rendered there.

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

    Takeaway: React keeps the state, and it finds that state by position, not by your component's name.

    React discards the state at a position as soon as nothing renders there, and it keeps no copy to restore the state from. Two documented ways keep a value past that point, and both keep the value at a position that stays rendered:

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

    For example, a review panel still shows the previous candidate's rating after you switch candidates. The fetch returned the right data, and the Rating component stayed at the same position with the same type, so React kept its stored state and passed the new props to the same component.

  • Props are the arguments of one render

    The common read: props are a live connection to the parent's data, so writing a field on the props object is how a child sends a change back to the parent

    A component function takes exactly one argument: a props object that belongs to one call only. The next render is a different call with a different object, and the parent decides what goes in it. Write a field on the props object and a development build throws: React froze the object shallowly before your function received it, the same freeze lesson 1 showed on the element.

    Now write a field on an object that a prop holds. That write is

    Takeaway: The key word is "shallowly": React freezes the props object itself, not the objects its fields point to, and it does not copy those objects either.

    Write a field on an object a prop holds and nothing stops you: that object belongs to the parent, so the parent's data is now wrong. The fix is to ask the parent to pass a new object, not to edit the one you received. Copying a prop into useState lets the value go out of date with no freeze to catch it: the copy was correct once and never updates.

    For example, a bug is filed against a billing service that returned the new plan, while the header still shows the old plan name.

  • A state variable is a constant of its render

    The common read: the state variable is a live reference to the value React is holding, so a write changes it and the new value is there a moment later, and I can read it back after an await or a delay

    Move that same assignment into a click handler and the screen does not update. React calls your function once per render and passes it that render's state values, and those values stay fixed for as long as any code belonging to that render can still run, including code that resumes after an await three seconds later. You edited your copy, React's stored value did not change, and the next render reads the value React holds.

    The correct name for this bug is

    For example, a handler increments an attempt count, then checks that same count to decide whether to keep trying. The check reads the number from before the click, so the handler gives up one click later than its author intends, on every run. An ordinary mutable value behaves differently: write a ref and read it back in the same tick and you get the value you wrote. A state variable read back the same way still returns the old value.

    Takeaway: Nothing crashes, so the bug is found in a log line and filed against the setter, which is the one line in the handler that works correctly.

  • Every render creates its own handlers

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

    A timer holds a callback you registered three renders ago, and the timer fires after those three renders. A re-render does not update existing handlers: it creates new ones.

    The screen has re-rendered twice while a save request was pending. When the awaited handler resumes and reads the draft, it gets

    Takeaway: Every render creates its own functions, and each function closes over that render's values. The callback a timer holds, the code after an await, and the body of a for loop still running three commits later are all closures over the render that created them, and a later commit does not change them.

    Handlers that read an old render's values cause wrong data, not only confusing logs. A handler that is still running and reads a cancellation flag, a quota, a recipient or a draft gets the value from when the user acted, not the value at the time of the write, so the request is sent with out-of-date input and writes wrong data instead of nothing.

    For example, a user reports "the app saved the previous document's title", the bug is filed against whatever queue performed the write, and the cause is a pull request from weeks earlier that 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 the state becomes

    The left button lost an increment because a setter call does not assign. A setter call appends one entry to a queue, and React processes that queue during the next render, after your handler has finished running. The queue explains batching and its limit: three setters in one handler cause one render, and two separate clicks produce two queues that React never merges.

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

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

    The left button is already wrong on its first click: it shows 1 where the code asks for 2, and React logs no error. Real code rarely writes the plain call twice in a row. It sits in a helper, and one event handler calls that helper once per message in a batch. A test with one message passes. For example, a user reports "the unread badge is off by a few by lunchtime", and the bug is filed against a socket that delivered every message correctly.

    Keep updaters pure and have them only return the result: StrictMode runs each updater twice and discards the second result, to help you find updaters that are not pure.

  • An Effect that sets state causes a render, a commit, and a second render

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

    A derived value is stored in state, and an Effect keeps it in sync.

    The synchronization 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 process: React renders, commits, runs the Effect, then renders and commits a second time, all for a value the first render could have computed.

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

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

    useMemo is for the case where the computation itself is slow, which is a separate problem. A common production failure 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. The bug is then filed as a performance problem against the list.