Why does reading state back right after writing it give the old value in some component frameworks and the new one in others?
answer
- two different write models
- snapshot per pass vs live cell
- a queued write marks dirty
- the value already read cannot change
- immediate cell, deferred dependents
basics
~20 sTwo write models. A runtime that re-runs components treats each pass's state as a fixed snapshot, so a queued write cannot change the value that pass already read. A fine-grained runtime updates the cell immediately and merely schedules dependents.
solid answer
~40 sIt depends on whether the write is queued against a snapshot or applied to a cell. A runtime that produces output by re-running the component function gives each run a fixed view of state: a write appends to that instance's update queue and marks it as needing another pass, so the variable this run holds keeps the old value until the next pass reads a new snapshot. A runtime with fine-grained tracking keeps state in an observable cell, so the write lands at once and reading it back gives the new value - what is deferred there is not the value but the dependent work. Both models coalesce the writes of one event into a single update pass; they differ only in whether the value is observable before that pass runs.
go deeper
Recall the symptom: right after writing state, the variable you wrote can still read old inside the same handler. Use the value you just wrote instead of reading it back.
Explain both models - a per-pass snapshot with a queued write versus a live cell written immediately - and say what each one defers: the value, or only the work the value causes.
Show how you catch it in review: code that branches on a value it just wrote, or inspects output before the flush. Pick the fix that matches the runtime instead of reaching for a synchronous flush.
Set the convention: derive from the value you already hold rather than reading state back, and keep synchronous flushes rare and reviewed, because each one gives up the coalescing the whole application leans on.
A state write is two requests at once: **change this value** and **make the output reflect it**. Frameworks answer the first request at different moments, and the write-then-read-back puzzle is the visible edge of that difference. ## Model A: a per-pass snapshot with a queued write In a runtime that produces output by re-running a component function, each run works from a **snapshot** of the instance's state that was fixed when the run began. A write does not reach back into a run already in progress. Instead it: 1. appends the requested change to that instance's update queue; 2. marks the instance as needing another pass; 3. returns immediately, so the handler keeps running. The consequences follow mechanically: - Reading the state variable after writing it returns the snapshot value, because that is the only value this run ever had. - Two plain writes to the same cell in one handler both compute from the same starting value, so the second overwrites the first. - A write expressed as a *function of the previous value* composes instead, because the runtime applies the queued functions in order while computing the next snapshot. - Closures created during the pass capture the snapshot too, which is why a callback handed to a timer before the write still reports the old value when it finally runs. ## Model B: a live cell written immediately In a runtime with fine-grained tracking, state lives in an observable cell and a write **mutates the cell now**. Any read afterwards - the next line, another function, a derived computation evaluated later - sees the new value. What is deferred is the *work*: the write notifies the cell's dependents, and those are queued and flushed once. A compile-time runtime that rewrites plain assignments into notifications looks the same from the caller's side: the variable holds the new value, and the update is scheduled. ## The two models side by side | | re-run-the-function runtime | fine-grained or compile-time runtime | |---|---|---| | when the value changes | when the next pass reads its snapshot | at the moment of the write | | reading it back in the same handler | old value | new value | | what the write schedules | another pass over the dirty instance | re-evaluation of the dependents only | | what re-runs | the component function, parent before child | the specific computations that read the cell | | the classic bug | branching on a value you just wrote | assuming the output is applied because the value is | ## Why the snapshot model is a feature, not an oversight A pass whose state could change underneath it could not promise a consistent screen: two parts of the same output could read the same state and disagree. Freezing state for the duration of a pass makes the output a function of a fixed input, which is what makes it safe for the runtime to re-run that pass, throw it away, or run it later at a different urgency. The cost is exactly the surprise under discussion - inside the pass that issued the write, the new value is not observable. ## "It is asynchronous" is the wrong diagnosis Nothing is awaited here. In a snapshot runtime the write is complete and durable the instant it returns; it is *recorded*, not *in flight*. Calling it asynchronous invites two wrong fixes: awaiting something that does not exist, and adding a delay until the read happens to work. The accurate sentence is "the value applies to the next pass, not this one", and that sentence points at the right fix. ## Neither model updates the output immediately Whatever the write model, the writes made during one event are coalesced into a single update pass, flushed after the event's synchronous code finishes - on a microtask, on the next tick, or before the next frame, depending on the runtime. So the immediate-value model is not "faster to update"; it is only more honest about the value. Code that writes state and then inspects host nodes is wrong in **both** models, and a runtime whose value is visible at once makes that mistake easier to fall into. ## How to write code that does not care which model it is on - Use the value you just wrote rather than reading state back. - Express a dependent write as a function of the previous value where the runtime supports it, so ordering composes instead of overwriting. - When you need to act on applied output, use the runtime's after-update callback or the signal it resolves once its queue has drained. - Keep the synchronous-flush escape hatch for the read that genuinely cannot wait, and accept that it gives up coalescing for that event. - Derive a value once per pass and pass it down, rather than reading the same state repeatedly and hoping every read agrees.
- In a fine-grained runtime the value updates immediately - does that mean the rendered output is updated immediately too?No. The cell changes synchronously, so every later read sees the new value, but the dependent computations and host updates are still queued and flushed once for the whole event. Immediate value visibility and immediate output are separate guarantees, and confusing them produces code that inspects the tree too early.
- If a write is queued, how do you run code only after the new value is on screen?Use the runtime's after-update signal: the lifecycle callback it runs once it has applied the update, or the promise or callback it resolves when its queue has drained, and put the read there. The synchronous-flush escape hatch also works, but it pays for the privilege with an extra pass, so keep it for reads that cannot wait.
- Does a queued write make state eventually consistent and therefore unreliable?No. The queue is drained before the next pass produces output, so every pass sees a consistent view of all writes made before it. What you give up is only mid-handler visibility inside the pass that issued the write, and that is a deliberate trade for a consistent screen.
saying these in an interview costs you the question
- Claims the write has not finished yet, as if it were asynchronous I/O
- Says a race between two writes lost the value
- Believes reading the variable again forces the update to apply
- Assumes every component framework behaves identically here
- Treats an immediately visible value as proof the output is applied
- Blames a caching bug in the framework for the stale read