Dragging one row to the end of a 500-row list stalls the interface, though only that row moved — what is the update paying for?
answer
- count three things, not one
- 500 comparisons before one move
- fresh per-row values defeat the gate
- moves are cheap host operations
- keep drag state out of the list owner
basics
~20 sThree counts hide behind one move: descriptions produced, comparisons performed, host operations applied. Relocating a row is one or two host operations, but the pass still compares all 500 siblings and re-enters every row whose inputs were rebuilt.
solid answer
~50 sSeparate the counts before blaming the move. Assuming children are matched by stable identity rather than by position, the **host** work for relocating one row is tiny — a node moved, nothing else rewritten. The **pass** work is not: the list's output code produces 500 row descriptions again, and the sibling comparison walks 500 old against 500 new to discover that one position differs. Then, for each row, either the runtime stops at the row boundary because the row's inputs are unchanged, or it descends and re-describes that row's whole subtree. That last branch is usually the stall: if the list builds a fresh object, array or inline handler per row on each update, no row can bail out, so one move pays for 500 subtree passes. The levers, in order: make per-row inputs stable so each row stops at a shallow comparison, shrink what a row describes, and keep the drag's transient state out of the region that owns the list.
go deeper
Understand that describing all the rows again is not the same as rebuilding them on screen, and that moving one row is a cheap operation for the host.
Separate descriptions produced, comparisons performed and host operations applied, and explain why a value built per row during mapping stops that row from being skipped.
Show the diagnosis order: measure pass versus host, establish whether 500 subtrees or 500 boundaries were visited, then stabilise inputs, shrink the row, and move transient drag state out of the list's owner.
Set the expectation that high-count repeated children get their own cost rules, and that the conventions they justify do not generalise to the rest of the application.
A reorder is the update where people's intuition about cost breaks down most reliably, because the visible change is one row and the work is not. ## Count three things, not one 1. **Descriptions produced.** If the list's output is produced by re-running the code that maps data to rows, all 500 row descriptions exist again, regardless of how few moved. 2. **Comparisons performed.** The sibling comparison walks the new sequence against the previous one to work out which of the previous children each new child corresponds to and what moved. That walk is over the whole sequence. 3. **Host operations applied.** For a single relocation with children matched by stable identity, this is about one node move. The host also does layout for the affected region afterwards. Only the third count matches your intuition about "one row moved". The stall lives in the first two, and in what happens *below* each row boundary. ## What decides whether each row is entered At each row position the pass either descends into the row's subtree or stops. It stops when the row's boundary is gated and its inputs compare unchanged. It descends when there is no gate, or when the comparison misses. Misses are usually manufactured by the list itself: - A per-row object or array constructed while mapping the data. - An inline handler created per row on each update. - A value derived per row inside the mapping rather than once outside it. Each of those is a new value per update, so a shallow comparison at the row boundary sees a difference every time. The consequence is stark: a reorder that should cost 500 shallow comparisons instead costs 500 full row passes, each re-describing that row's whole subtree. ## The same reorder under other strategies | Strategy | Descriptions produced | Comparisons | Host work | |---|---|---|---| | Snapshot and diff | All rows re-described unless each row boundary bails out | The full sibling sequence | One relocation | | Snapshot and diff, rows gated with stable inputs | The list level only | The full sibling sequence plus one shallow check per row | One relocation | | Fine-grained subscriptions | None below the list; the changed sequence notifies the list binding | The sequence, to compute the moves | One relocation | | Compile-time targeted | Rows are prepared instructions; the dynamic sequence still needs matching | The sequence | One relocation | The pattern across the row: nobody escapes walking the sequence, because working out what moved requires looking at the sequence. What differs is whether the *contents* of 500 rows are described and compared on the way. ## Diagnosing it in order 1. **Confirm where the time goes.** Pass time versus host time. If the host dominates, the row's own structure or styling is forcing expensive layout, and the pass is not the problem. 2. **Check the visited set.** Does the update visit 500 row subtrees or 500 row boundaries? That single fact chooses the fix. 3. **Stabilise per-row inputs** so the boundary check can match: hoist handlers and derived values out of the mapping, pass primitives where possible, pass the row's own data object rather than a freshly wrapped copy. 4. **Shrink the row.** A row that describes forty nodes multiplied by 500 is a large pass even when it is unavoidable; fewer wrappers is a real win at this multiplier. 5. **Keep the drag's transient state out of the owner of the list.** If the position under the pointer is stored where the list is owned, every pointer move re-describes the list; stored closer to the dragged element, it does not. 6. **Reduce the number of rows the update must consider at all.** How that is done belongs to list-rendering design rather than to the update strategy, but it is the lever with the largest ceiling. ## What not to conclude - Not "reordering is inherently expensive". A move with stable identity is close to the cheapest host operation a list can undergo. - Not "the pass is the problem, so avoid describing declaratively". The pass is the problem only in proportion to what it visits. - Not "add gates everywhere". At this multiplier a gate per row is well justified; that same reasoning does not transfer to a page with ten components. - Not "measure once". A move-heavy interaction fires many updates per second; a per-update cost that looks acceptable in isolation is what makes the interaction feel stuck.
- Why does no strategy escape walking the whole sibling sequence on a reorder?Because deciding what moved is a property of the sequence, not of any one child. Whatever the runtime knows about which values changed, it still has to line the new order up against the previous one to emit the right relocations. The strategies differ in whether they also re-describe each child's contents while doing that.
- The pass is cheap but the drag still stutters. Where do you look?At host cost. Relocating a node forces the host to lay out the affected region, and a row whose styling makes layout expensive multiplies that over many updates per second. Look at layout and paint work rather than the update strategy, and reduce what each relocation invalidates.
- Is a gate at every row boundary a reasonable thing to institutionalise?At this multiplier, yes: one shallow comparison against a whole row pass, repeated hundreds of times, is a clear win. The conclusion is local to high-count repeated children, though, and it comes with the obligation that whatever feeds those rows keeps its values stable, which is a discipline the codebase has to keep honouring.
saying these in an interview costs you the question
- Counts only host writes and treats the comparison pass as free
- Thinks moving one row means the pass touches one row
- Believes reordering is inherently as expensive as rebuilding the list
- Expects a boundary gate to help while each row gets a newly built input
- Assumes host layout always dominates whatever the pass did