Flutter / Dart, widgets, and local ownership / Lesson 6
Lists, identity, and selection
Render collections lazily, preserve item identity, and store selection using stable domain identifiers.
On this page
Lesson goals
- Separate a row position from an item identity
- Use lazy builders for large scrolling collections
- Key sibling rows by stable identifiers
Separate a row position from an item identity
An index describes where an item happens to appear in one ordering. A stable identifier describes which domain item it is. Filtering, sorting, inserting, or deleting changes positions without necessarily changing identities. If selection is stored as index 2, a new leading item can silently select a different record. Store the selected identifier and derive the selected row from current data. Decide explicitly what happens when that identifier is no longer present: clear selection, show a missing-item state, or keep a deliberate cross-page selection according to the product contract.
Use lazy builders for large scrolling collections
ListView.builder requests rows according to viewport needs rather than building every row eagerly in one Column. Supply itemCount when known so the list has a meaningful boundary. A builder callback should derive a row from current data and avoid starting a separate fetch each time a row is requested. A row leaving the viewport may later be recreated, so durable domain state belongs in the model rather than only in an offscreen row. Lazy construction reduces some costs; it does not make an expensive image decode or complex layout free.
Key sibling rows by stable identifiers
The first fragment uses a ValueKey based on each synthetic record identifier. The key tells reconciliation which sibling description represents the same logical row after ordering changes. It belongs on the widget at the changing sibling boundary, not buried under an unkeyed wrapper that owns the actual sibling position. A key is not a global database lookup and need not expose private data. Duplicate keys among siblings violate the identity contract. When stateful rows reorder in a builder, review the documented findChildIndexCallback behavior as well rather than assuming a key solves every recycling scenario.
final records = <({String id, String title})>[
(id: 'book-7', title: 'Maps'),
(id: 'book-9', title: 'Rivers'),
];
Widget rows() => ListView.builder(
itemCount: records.length,
itemBuilder: (context, index) {
final item = records[index];
return ListTile(key: ValueKey(item.id), title: Text(item.title));
},
);
Two keyed rows display Maps and Rivers in the current order.
The record syntax requires a compatible Dart SDK. The surrounding Flutter host and bounded viewport are intentionally omitted.
Replace collections under a clear owner
The second fragment derives a new set when toggling an identifier. A fresh collection makes the transition explicit and avoids mutating a set shared with an earlier state snapshot. The UI owner would assign that returned set inside its chosen state mechanism. This is useful when tests compare before and after states or when another component retains a read-only snapshot. For larger models, immutable updates still require attention to nested values. A shallow spread copies collection membership, not every referenced object, so document where mutable objects are allowed.
Set<String> toggle(Set<String> selected, String id) {
final next = <String>{...selected};
if (!next.add(id)) next.remove(id);
return next;
}
void main() {
final before = <String>{'book-7'};
final after = toggle(before, 'book-9');
print(before.length);
print(after.contains('book-9'));
}
1
true
The original set remains unchanged. Calling toggle again for the same identifier would remove that identifier from the new result.
Represent empty and changing collections honestly
An empty filtered result is different from a loading operation or a failed request. Give each a distinct state so the interface can explain what happened and offer an appropriate next action. If an item disappears while selected, derive the visible selection safely rather than indexing into a stale list. Avoid modifying the same list while a builder is reading it; publish a consistent snapshot under one owner. During pagination, distinguish the initial load from loading another page so existing useful rows do not vanish behind a full-screen spinner.
Test identity through an actual reorder
A useful identity test edits the draft in one row, reorders the collection, and verifies that the draft still belongs to the same identifier. Merely checking that all titles are visible cannot detect state migration between rows. Also test insertion at the front, deletion of the selected row, duplicate identifiers rejected at the data boundary, and an empty filter result. When a list has interactive controls, verify their semantic labels and focus behavior after movement. Identity is part of correct interaction, not only a performance optimization or a way to silence framework diagnostics.
Common errors and fixes
Selection jumps after inserting a row
- Why it happens
- The selected position was stored instead of stable identity.
- How to fix it
- Store the domain identifier and derive the current row.
- Prevent it next time
- Test front insertion and filtered ordering.
Two siblings report duplicate keys
- Why it happens
- The chosen key is not unique within that sibling collection.
- How to fix it
- Validate identifiers and choose the actual item identity.
- Prevent it next time
- Reject malformed duplicate input before rendering.
Offscreen drafts vanish unexpectedly
- Why it happens
- Durable draft state lived only inside disposable row state.
- How to fix it
- Move durable drafts to an owner keyed by item identifier.
- Prevent it next time
- Define whether leaving the viewport may discard local work.
Check your understanding
1. Why is ValueKey(index) inadequate for editable rows that reorder?
Show worked solution
The index preserves the slot identity, not the domain item identity. After sorting, the same slot may represent another item and retain the wrong local state. Key by a stable item identifier at the sibling boundary and test the edited row through reordering.
2. A selected identifier disappears during refresh. What should the model do?
Show worked solution
Choose an explicit policy. For a single-page selection, intersect selection with current identifiers and explain that the item is unavailable if needed. For cross-page selection, retain the identifier separately while resolving availability before any command. Never reinterpret it as the item at the old index.
Key takeaways
- Separate a row position from an item identity
- Test identity through an actual reorder