skip to content

In a Flutter playlist, sorting moves each stateful TrackTile's checkbox tick to the wrong track, and adding ValueKey(track.id) to TrackTile only made ticks vanish; what is going on?

level: seniorimportance: should knowfreq 47%

answer

  1. keys compare among siblings only
  2. which widget does the list see
  3. Padding wrapper is keyless
  4. inner key mismatch means new State
  5. findChildIndexCallback for builders

basics

~20 s

Keys are only compared among one parent's direct children. The list sees keyless wrappers matched by position, so the inner TrackTile's key mismatches and its State is recreated. Key each item's outermost widget; builder lists also need findChildIndexCallback.

solid answer

~50 s

Before the fix, the tiles were keyless, same-type siblings, so `Widget.canUpdate` matched them by position and each `State` (with its tick) stayed at its index while the tracks moved. The fix failed because each item is `Padding(child: TrackTile(key: ...))`: the list compares only its direct children, the keyless `Padding` widgets, so they still match by position. Inside each reused `Padding`, the old `TrackTile` has one track's key and the new one has another's, so `canUpdate` is false, the old element is unmounted and a fresh `State` starts unticked. Put the key on the outermost widget of each item. In `ListView.builder` also pass `findChildIndexCallback` (on `ListView.separated`, `findItemIndexCallback`); otherwise a keyed item at a new index is rebuilt, not moved, and still loses its state. Better still, keep the selection in the model, as a set of track ids.

code

dart · 16 lines
dart
ListView.builder(
  itemCount: tracks.length,
  findChildIndexCallback: (Key key) {
    final String id = (key as ValueKey<String>).value;
    final int index = tracks.indexWhere((Track t) => t.id == id);
    return index == -1 ? null : index;
  },
  itemBuilder: (BuildContext context, int index) {
    final Track track = tracks[index];
    return Padding(
      key: ValueKey<String>(track.id), // on the outermost widget
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: TrackTile(track: track),
    );
  },
)

go deeper

for a junior

Remember that keys are compared only among one parent's direct children, so the key must go on the outermost widget of each list item.

for a middle

Trace both symptoms through canUpdate: positional reuse moves state, while a nested key mismatch recreates it.

for a senior

Diagnose from the symptom which layer is wrong, add findChildIndexCallback to builder lists, and move user-visible selection into data keyed by id.

for a principal

Push for state ownership rules in the codebase, so persistent user choices never live in list-item State where identity bugs can corrupt them.

## The two symptoms have one cause Both bugs come from the same rule. When a parent rebuilds, Flutter pairs its old child elements with the new child widgets and reuses an element when `Widget.canUpdate(old, new)` holds: same `runtimeType` and equal `key`. **That comparison happens only between the direct children of one parent.** A key nested deeper inside an item is invisible to the list. - **Symptom 1, ticks move to the wrong track.** No keys at all: every item has the same type and a null key, so items are paired by position. The `State` holding the tick stays at index 2 while a different track sorts into index 2. - **Symptom 2, ticks vanish.** The key was added one level too deep. The list still pairs keyless wrappers by position, and inside each wrapper the keyed `TrackTile` now fails `canUpdate`, so its element is replaced and the `State` restarts unticked. ## Tracing the failed fix The item builder looked like this: ```dart itemBuilder: (context, index) { final Track track = tracks[index]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TrackTile(key: ValueKey(track.id), track: track), // key too deep ); }, ``` After sorting, step by step: 1. The list compares its direct children: `Padding` against `Padding`, both keyless, so element 0 is reused for the new item 0. 2. Inside that reused `Padding`, the old child is `TrackTile` with `ValueKey('t7')`, the new one has `ValueKey('t3')`. 3. `canUpdate` is false, so the old `TrackTile` element is deactivated and later disposed, and a new element with a new `State` is mounted. 4. The new `State` starts with its default `false`, so the tick disappears for every track that changed position. ## The fix, depending on the list widget 1. **Key the outermost widget of each item**: `Padding(key: ValueKey(track.id), child: TrackTile(track: track))`, or move the padding inside `TrackTile`. 2. **`Column`, `Row` or `ListView(children: ...)`**: that is enough. Multi-child elements match leftover children by key, and the `children` list delegate looks up a moved key's new index for you. 3. **`ListView.builder`**: items are built lazily by index, so the list cannot know where a key went unless you pass `findChildIndexCallback`, which returns the new index for a key or `null` if it is gone. Without it, the docs warn that reordering "may result in state-loss": the keyed item at a changed index is replaced rather than moved. 4. **`ListView.separated`**: use `findItemIndexCallback`; the older `findChildIndexCallback` there counted separators too and was deprecated after `v3.37.0-1.0.pre`. ## If the playlist is a `ReorderableListView` - `ReorderableListView` asserts that every item has a key ("All children of this widget must have a key."). `ValueKey(index)` satisfies that assertion but is still positional, so it reproduces symptom 1 after a drag. - Report drags with **`onReorderItem`**; `onReorder` was deprecated after `v3.41.0-0.0.pre` because it made callers subtract one from `newIndex` when moving an item down. `onReorderItem` already adjusts it. ## The senior answer: the tick belongs to the data | Where the "selected" flag lives | Survives sorting? | Survives the tile scrolling away? | |---|---|---| | A `bool` in `TrackTile`'s `State`, keyless | No, it follows the index | No | | A `bool` in `State`, correctly keyed | Yes | No, unless kept alive: a lazily built tile far off screen is disposed | | A `Set<String>` of selected ids in the parent or model | Yes | Yes | Keys make element identity follow the data, which is essential for genuinely ephemeral state such as a running animation, a focused text field or a scroll position inside a tile. A checkbox that the user expects to persist is application data; holding it as a set of ids above the list makes the tile stateless for that purpose and removes the whole class of bug. ## A debugging checklist - Ask **which widget the parent sees**: the key must sit on the widget returned directly from the item builder or placed directly in `children`. - Check for **index-derived keys** (`ValueKey(index)`) and **per-build keys** (`UniqueKey()` inline). - For builder lists, check for **`findChildIndexCallback`** (or `findItemIndexCallback` on `.separated`). - Confirm ids are **unique**; duplicates trip "Duplicate keys found." in debug builds.

  • Why does a Column handle keyed reorders without extra help while ListView.builder needs findChildIndexCallback?
    A `Column` receives its whole new child list at once, so its element can collect leftover old children by key and pair them with new ones. `ListView.builder` only builds items by index, on demand; to move an existing keyed element it must be told the new index for that key, which is exactly what `findChildIndexCallback` returns.
  • Would ObjectKey(track) have fixed the tile in the original bug?
    Only if the same `Track` instances survive the sort. Sorting an in-memory list keeps instances, so `ObjectKey` on the outermost widget would work there; but after a refetch that parses new objects, `identical()` fails and every tile resets. `ValueKey(track.id)` works in both cases.
  • Once the selection moves into a Set of ids in the parent, do the tiles still need keys?
    Not for the tick. They still need them if they hold other ephemeral state, such as an expansion animation or a text field, and a `ReorderableListView` requires a key on every item regardless.

saying these in an interview costs you the question

  • A key anywhere inside an item's subtree is enough for the list to track it.
  • ValueKey(index) fixes the bug because every tile now has a unique key.
  • ListView.builder moves keyed items to their new index without further help.
  • The vanished ticks mean setState was not called after sorting.
  • Wrapping each tile in a GlobalKey is the standard fix for sorted lists.