Mobile Computing · UI Frameworks

Element Identity and State Retention Across Rebuilds

Why declarative frameworks store component state in a slot list keyed by position rather than in the component object, and how that choice decides which state survives a rebuild.

In an object-oriented view of an interface, a control is an object and the object holds the control’s state. A counter widget has a field for the count; destroy the widget and the count is gone. Declarative frameworks break this correspondence on purpose. The description of a counter is rebuilt on every pass, so if state lived in the description it would be reset on every keystroke. State has to live somewhere that outlives the description but is still scoped to one instance of it, and that somewhere is determined by identity rather than by the object graph.

The consequences are concrete and occasionally surprising. It explains why two identical component calls in different branches of a conditional share nothing, why a list reorder can transplant typed text into the wrong row, and why a hook cannot be called inside an if. All three follow from a single storage decision.

State cannot live in the description

Flutter makes the separation explicit in its type names. A StatefulWidget is immutable, is rebuilt freely, and holds only the configuration passed by its parent. The mutable part is a separate State object that the framework creates once and associates with the widget’s position in the tree, then keeps across rebuilds. The framework calls build on that state object to obtain the new description. Because the state object is not the widget, mutating it and asking for a rebuild is legal; because it is not the description, it survives.

React reaches the same arrangement without a class boundary. A function component is invoked afresh each render and its local variables vanish when it returns. The values that must persist are held by the runtime, in a per-component record, and the component reaches them through calls that are recognized by their order of execution. The component function is a pure description of one render; the persistent part is external to it and addressed positionally.

Compose does the same with remember. The lambda is re-executed on recomposition, but a value stored with remember is retrieved from a table owned by the composition and returned again, so the identity of the stored object is stable across passes even though the code that requested it ran again.

Position in the tree is the identity

The key question is what the runtime uses to find the right record on the next pass. There are two candidates: an explicit identifier supplied by the developer, or the structural position at which the component was rendered. Mainstream frameworks use position as the default and offer keys as an override.

React’s position is the path through the element tree, extended at each list by the child’s key. Two components rendered at different positions are different components even if their props are identical, and each gets its own state. This is why unmounting a subtree and mounting an identical one in its place resets the state: the path is the same, but the framework has no way to know that the new mount is meant to continue the old instance, and it does not attempt to guess.

Within a single component, the position is a counter. Each call that requests persistent storage advances the counter, and the runtime indexes its record by that number. The first call in a render writes slot zero, the second slot one, and so on. On the next render the same sequence of calls reads the same slots back.

That mechanism dictates the rule that trips up every newcomer. A call inside a conditional branch does not execute on every render, so the counter no longer lines up: the call that used to be slot three is now slot two, and it reads the value stored for a different purpose. The framework cannot detect this reliably, because at the point of the call it knows only the counter value, not which source line produced it. React therefore states the constraint as a rule: calls that allocate persistent slots must run in the same order on every render, which rules out conditionals and loops around them and rules out early returns before them. Compose’s remember has the same positional character but is keyed additionally by the call site and by any explicit keys passed to it, so a conditional remember inside an if does not corrupt its neighbours; the slot table is a flat array with gaps, and each call site addresses its own region.

What identity protects, and what it does not

Identity preserves the framework’s own storage: hook slots, remembered values, state objects. It does not automatically preserve platform objects. Those are preserved by the reconciliation step described in Reconciliation: Turning a Declarative Description into Minimal Mutations, and the two mechanisms agree because both use the same type-and-key predicate, but they are separate passes with separate failure modes.

The distinction matters for a case like a text field. The framework’s record survives a rebuild, and the platform’s text input object survives because the reconciler reused it. If the reconciler decides the type changed, the platform object is destroyed, and the text it contained goes with it even though the framework’s slot for the component is untouched. A screen that swaps between two container types around the same field will lose the typed text and keep the component’s own counter, which is the correct outcome under the rules and confusing in practice.

Identity also does not extend across a change of parent. A component whose position moves because a wrapper element was inserted above it is at a different path, so it is a new instance with fresh state. This is the reason that adding a provider or a theme wrapper to an existing tree can reset half the screen, and why frameworks that support context or environment injection are careful to keep those wrappers out of the identity path where they can.

Keys as a developer-supplied identity

When a list reorders, position is the wrong identity: the item at index zero is a different item than it was last pass. A key replaces the positional component of the path with a developer-supplied value, so the record follows the item rather than the slot.

// The key becomes part of the identity path, so the input's state follows
// the row rather than the array index.
{rows.map((row) => (
  <Row key={row.id} row={row} />
))}

Three properties of keys are worth stating precisely, because the failure modes are not symmetric.

A key is scoped to its sibling list, not to the whole tree. Two rows in different lists may carry the same key without interference, and moving a keyed child from one parent to another does not preserve its state, because the parent component of the path changed.

A key participates in the path even when the list order is unchanged. Changing a key on an existing element forces an unmount and remount, which is occasionally the intended way to reset a subtree wholesale.

Keys are compared as strings in the DOM-facing implementations and by identity semantics elsewhere; numeric and string keys that print the same may or may not collide depending on the framework, so a single key type per list is the safe discipline.

SwiftUI exposes the same concept through Identifiable and the id parameter of ForEach, with one addition that the other frameworks do not have: a view’s identity can be structural, explicit, or type-based, and the framework documents which one wins. Structural identity is the position in the view hierarchy, explicit identity is a supplied value, and type identity distinguishes two views of different types at the same position. State declared with a property wrapper is tied to the resolved identity, so changing an explicit id on a view resets that view’s state even if nothing else about it changed.

The slot list as a data structure

It is worth seeing the storage concretely, because the constraints on it explain several framework behaviours.

class ComponentRecord:
    __slots__ = ('slots', 'deps', 'mounted')

    def __init__(self):
        self.slots = []      # persistent values, addressed by call index
        self.deps = []       # dependency arrays for memoized computations
        self.mounted = True

    def read(self, index):
        return self.slots[index] if index < len(self.slots) else _EMPTY

    def write(self, index, value):
        # Slots are append-only within a mount; a shrinking render is a bug,
        # not a resize, because the call sequence must be stable.
        if index < len(self.slots):
            self.slots[index] = value
        else:
            self.slots.append(value)

A flat array indexed by an integer, one per component instance, with no per-slot type information. That is the whole structure. Its costs are a pointer per slot and an allocation per component record; its benefit is that retrieving a value is an array index plus a bounds check rather than a hash of a key and a probe into a bucket.

The absence of type information is what makes misuse silent. A slot written by one call and read by another holds whatever object was stored, so a mismatched read produces a value of the wrong type rather than an error at the point of the read. Frameworks mitigate this in development builds with call-site tracking and ordering checks, at a cost they refuse to pay in release builds.

Where the model leaks

Three situations expose the positional scheme.

A component that returns different numbers of children depending on state, with those children each holding state, will see state migrate between them on the pass where the count changes, unless the children are keyed. The framework is behaving as specified; the description changed shape.

Suspending or deferring a subtree, as asynchronous rendering does, means the tree can be in a partially updated state where some records have been read for the new pass and others have not. Frameworks that support this keep two versions of the record and discard the abandoned one, which is why the slot array is treated as append-only and why a render that throws leaves the previous values intact.

Finally, state hoisted out of the component into an external store sidesteps identity entirely. A store keyed by a domain identifier survives every remount, including the ones that reset component state, and choosing between the two is a design decision about what the state belongs to: the position on screen, or the entity being displayed. That decision is the subject of Recomposition Scopes and Fine-Grained Invalidation, where the cost of reading state during a render becomes the dominant concern.

References

  1. React Documentation: Rules of Hooks — Meta Open Source
  2. React Documentation: Preserving and Resetting State — Meta Open Source
  3. Android Developers: Compose Mental Model — Google