Keys
The problem keys solve
Section titled “The problem keys solve”Recall reconciliation: when rebuilding, Flutter matches each new widget to the existing element by position and type. Usually that is exactly right. But in a list of same-typed stateful widgets, position-matching breaks when items move.
Imagine two TodoItem widgets (each stateful, each holding, say, a checkbox state). Swap their order in the list. Flutter sees “position 0 is still a TodoItem, position 1 is still a TodoItem” — types and positions match — so it keeps each element (and its state) exactly where it was. The data moved but the state did not. Result: you reorder the list and the checkboxes stay put on the wrong rows.
flowchart LR
subgraph before["Before swap"]
a0["item A (checked)"] --- a1["item B"]
end
subgraph after["After swap, NO keys"]
b0["item B
but still shows checked"] --- b1["item A
now unchecked"]
end
before -->|state stuck to position| after Proven: the reorder bug is real, and ValueKey fixes it
Section titled “Proven: the reorder bug is real, and ValueKey fixes it”Not a hypothetical — provable directly with a checkbox-holding StatefulWidget, checked at position 0, then reordered.
import 'package:flutter/material.dart';
class ToggleTile extends StatefulWidget { const ToggleTile({super.key, required this.label}); final String label; @override ToggleTileState createState() => ToggleTileState();}
class ToggleTileState extends State<ToggleTile> { bool checked = false; @override Widget build(BuildContext context) { return CheckboxListTile( title: Text(widget.label), value: checked, onChanged: (v) => setState(() => checked = v!), ); }}
/// useKeys=false: children get no key — matched by position only./// useKeys=true: children get ValueKey(label) — matched by identity.class ReorderHarness extends StatefulWidget { const ReorderHarness({super.key, required this.useKeys}); final bool useKeys; @override ReorderHarnessState createState() => ReorderHarnessState();}
class ReorderHarnessState extends State<ReorderHarness> { List<String> order = ['A', 'B']; void swap() => setState(() => order = order.reversed.toList());
@override Widget build(BuildContext context) { return Column( children: [ for (final label in order) ToggleTile(key: widget.useKeys ? ValueKey(label) : null, label: label), ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/reorder_keys.dart';
void main() { testWidgets('WITHOUT keys: checking "A", then swapping, leaves the check on the wrong label', (tester) async { final key = GlobalKey<ReorderHarnessState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: ReorderHarness(key: key, useKeys: false))));
// Check the tile currently at position 0 — its label is "A". expect(tester.widget<ToggleTile>(find.byType(ToggleTile).at(0)).label, 'A'); await tester.tap(find.byType(CheckboxListTile).at(0)); await tester.pump(); expect(tester.state<ToggleTileState>(find.byType(ToggleTile).at(0)).checked, true);
key.currentState!.swap(); // order becomes [B, A] await tester.pump();
// Position 0 now shows label "B" — but Flutter reused the SAME element // (matched by position, not identity), so its State is still checked. expect(tester.widget<ToggleTile>(find.byType(ToggleTile).at(0)).label, 'B'); expect( tester.state<ToggleTileState>(find.byType(ToggleTile).at(0)).checked, true, reason: 'WRONG: the check followed the POSITION, not the "A" label the user actually checked', ); expect(tester.widget<ToggleTile>(find.byType(ToggleTile).at(1)).label, 'A'); expect( tester.state<ToggleTileState>(find.byType(ToggleTile).at(1)).checked, false, reason: '"A" is now here, unchecked — its state was silently lost', ); });
testWidgets('WITH ValueKey: the check follows the label across the same swap', (tester) async { final key = GlobalKey<ReorderHarnessState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: ReorderHarness(key: key, useKeys: true))));
expect(tester.widget<ToggleTile>(find.byType(ToggleTile).at(0)).label, 'A'); await tester.tap(find.byType(CheckboxListTile).at(0)); await tester.pump();
key.currentState!.swap(); await tester.pump();
expect(tester.widget<ToggleTile>(find.byType(ToggleTile).at(0)).label, 'B'); expect(tester.state<ToggleTileState>(find.byType(ToggleTile).at(0)).checked, false, reason: 'B was never checked'); expect(tester.widget<ToggleTile>(find.byType(ToggleTile).at(1)).label, 'A'); expect( tester.state<ToggleTileState>(find.byType(ToggleTile).at(1)).checked, true, reason: 'CORRECT: ValueKey(label) identifies the element by its data, so the check followed "A" to its new position', ); });}00:00 +0: WITHOUT keys: checking "A", then swapping, leaves the check on the wrong label00:00 +1: WITH ValueKey: the check follows the label across the same swap00:00 +2: All tests passed!(trimmed from the real flutter test test/lessons/widgets__keys/ run on this Mac)
Keys give an element an identity
Section titled “Keys give an element an identity”A key tells Flutter “this element’s identity is X, not just its position.” Now, when matching new widgets to elements, Flutter matches by key — proven above, state follows the widget even when it moves.
// Give each item a stable key derived from its data:return ListView( children: [ for (final todo in todos) TodoItem(key: ValueKey(todo.id), todo: todo), ],);With ValueKey(todo.id), swapping the list reorders the elements to match — each TodoItem’s state travels with its id. The rule of thumb: you need keys when you have a list of stateful widgets whose order or membership can change. Stateless lists rarely need them.
The four kinds of key
Section titled “The four kinds of key”| Key | Identity based on | Use when |
|---|---|---|
ValueKey(v) | a value (id, string) | Most list items — a stable data value identifies each |
ObjectKey(o) | object identity | The identifying thing is an object, not a simple value |
UniqueKey() | always unique | You want to force a new element (discard old state) every build |
GlobalKey() | app-wide unique | You need to access a widget state or its context from elsewhere (use sparingly) |
Proven: UniqueKey() forces a fresh State on demand
Section titled “Proven: UniqueKey() forces a fresh State on demand”A plain rebuild with the same key reuses State (proven in the Stateless vs Stateful lesson). Assigning a brand-new UniqueKey() on demand is the deliberate opposite: it forces Flutter to treat the widget as a completely new identity, discarding the old element and State on purpose — useful for “reset this form/animation/text field to a blank slate” without a dedicated reset method on every field.
import 'package:flutter/material.dart';
int nextRemountId = 0;
class Stamped extends StatefulWidget { const Stamped({super.key}); @override StampedState createState() => StampedState();}
class StampedState extends State<Stamped> { late final int id; @override void initState() { super.initState(); id = nextRemountId++; }
@override Widget build(BuildContext context) => Text('$id');}
class ForceRemountHarness extends StatefulWidget { const ForceRemountHarness({super.key}); @override ForceRemountHarnessState createState() => ForceRemountHarnessState();}
class ForceRemountHarnessState extends State<ForceRemountHarness> { Key childKey = UniqueKey(); void forceRemount() => setState(() => childKey = UniqueKey()); void rebuildOnly() => setState(() {}); // no key change
@override Widget build(BuildContext context) => Stamped(key: childKey);}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/unique_key_remount.dart';
void main() { testWidgets('UniqueKey forces a fresh State on demand; a plain rebuild does not', (tester) async { nextRemountId = 0; final key = GlobalKey<ForceRemountHarnessState>(); await tester.pumpWidget(MaterialApp(home: ForceRemountHarness(key: key))); expect(tester.widget<Text>(find.byType(Text)).data, '0');
key.currentState!.rebuildOnly(); await tester.pump(); expect(tester.widget<Text>(find.byType(Text)).data, '0', reason: 'no key change — same State reused, same id');
key.currentState!.forceRemount(); await tester.pump(); expect(tester.widget<Text>(find.byType(Text)).data, '1', reason: 'UniqueKey() forced a brand-new State — a new id was stamped'); });}00:00 +0: UniqueKey forces a fresh State on demand; a plain rebuild does not00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/widgets__keys/ run on this Mac)
GlobalKey: cross-tree access, and the real duplicate-key crash
Section titled “GlobalKey: cross-tree access, and the real duplicate-key crash”GlobalKey is the heavy one: it uniquely identifies a widget across the entire app and lets you reach its State or BuildContext from anywhere — no ancestor/descendant relationship required. The most common legitimate use is form validation, where a parent needs to trigger validation logic that actually lives on the FormState:
final formKey = GlobalKey<FormState>();
Form( key: formKey, child: TextFormField( validator: (value) => (value == null || value.isEmpty) ? 'Required' : null, ),);
// Elsewhere — a Save button, with no other path to the Form's internals:if (formKey.currentState!.validate()) { // proceed}The cost of GlobalKey’s power: a GlobalKey can only be attached to one widget in the tree at a time. Using the same GlobalKey on two widgets simultaneously is a real, provable crash — not a style warning:
import 'package:flutter/material.dart';
class DuplicateGlobalKeyDemo extends StatelessWidget { DuplicateGlobalKeyDemo({super.key}); final dupKey = GlobalKey();
@override Widget build(BuildContext context) { return Row( children: [ Padding( padding: const EdgeInsets.all(1), child: SizedBox(key: dupKey, width: 10, height: 10), ), // BUG: the exact same GlobalKey, on a second, unrelated widget. // Deliberately a DIFFERENT widget type than Padding above, so this // isn't caught by the simpler "direct siblings with the same key" // check first. // ignore: avoid_unnecessary_containers Container(child: SizedBox(key: dupKey, width: 20, height: 20)), ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/duplicate_global_key.dart';
void main() { testWidgets('reusing a GlobalKey on two widgets throws the real duplicate-key error', (tester) async { await tester.pumpWidget(MaterialApp(home: DuplicateGlobalKeyDemo()));
final error = tester.takeException(); expect(error, isNotNull); expect(error.toString(), contains('Multiple widgets used the same GlobalKey.')); expect(error.toString(), contains('A GlobalKey can only be specified on one widget at a time in the widget tree.')); });}00:00 +0: reusing a GlobalKey on two widgets throws the real duplicate-key error00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/widgets__keys/ run on this Mac)
The exact, un-trimmed assertion text, captured from a live run:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════The following assertion was thrown while finalizing the widget tree:Multiple widgets used the same GlobalKey.The key [GlobalKey#db66e] was used by multiple widgets. The parents of those widgets were:- Padding(padding: EdgeInsets.all(1.0), dependencies: [Directionality], renderObject:RenderPadding#eaf16 relayoutBoundary=up1)- ContainerA GlobalKey can only be specified on one widget at a time in the widget tree.It is powerful but expensive and easy to misuse — prefer local keys (ValueKey, UniqueKey) unless you truly need cross-tree access, and never build a GlobalKey inside a widget that itself might appear more than once (e.g. one GlobalKey per list item is a common way to accidentally trigger exactly this crash if the list ever renders the same key twice, such as after a bad deduplication bug in the data).
Proven: PageStorageKey preserves scroll position across removal and re-add
Section titled “Proven: PageStorageKey preserves scroll position across removal and re-add”A ListView remembers its own scroll offset while it stays mounted. The problem PageStorageKey solves is different: what happens when the same logical list (e.g. a tab’s content) is torn down and rebuilt from scratch — switching away from a tab that doesn’t keep its children alive, then switching back? Without an explicit identity, the new Scrollable element has no way to find the old offset. PageStorageKey, read through the ambient PageStorage/PageStorageBucket, gives it one.
import 'package:flutter/material.dart';
class ScrollPage extends StatelessWidget { // A separate `pageStorageKey` field (not `super.key`) on purpose: this is // the key that goes on the INNER ListView, not on ScrollPage itself. const ScrollPage({super.key, this.pageStorageKey}); final Key? pageStorageKey;
@override Widget build(BuildContext context) { return ListView.builder( key: pageStorageKey, itemCount: 100, itemExtent: 50, itemBuilder: (context, i) => SizedBox(height: 50, child: Text('Item $i')), ); }}
/// showList toggles between the ListView and nothing — simulating switching/// away from, then back to, a tab that does NOT keep its children alive/// (unlike a TabBarView wrapped in AutomaticKeepAliveClientMixin/IndexedStack).class TabsHarness extends StatefulWidget { const TabsHarness({super.key, required this.usePageStorageKey}); final bool usePageStorageKey; @override TabsHarnessState createState() => TabsHarnessState();}
class TabsHarnessState extends State<TabsHarness> { bool showList = true; void hide() => setState(() => showList = false); void show() => setState(() => showList = true); final bucket = PageStorageBucket();
@override Widget build(BuildContext context) { return PageStorage( bucket: bucket, child: showList ? ScrollPage(pageStorageKey: widget.usePageStorageKey ? const PageStorageKey('scroll-list') : null) : const SizedBox(), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/page_storage_scroll.dart';
void main() { testWidgets('WITHOUT a PageStorageKey, scroll position resets when the list is removed and re-added', (tester) async { final key = GlobalKey<TabsHarnessState>(); await tester.pumpWidget(MaterialApp(home: TabsHarness(key: key, usePageStorageKey: false)));
await tester.drag(find.byType(ListView), const Offset(0, -1000)); await tester.pump(); expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 1000.0);
key.currentState!.hide(); // simulate switching away from this tab await tester.pump(); key.currentState!.show(); // simulate switching back await tester.pump();
expect( tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0.0, reason: 'no PageStorageKey — the new ListView element has no identity to recover the old offset from', ); });
testWidgets('WITH a PageStorageKey, scroll position survives removal and re-add', (tester) async { final key = GlobalKey<TabsHarnessState>(); await tester.pumpWidget(MaterialApp(home: TabsHarness(key: key, usePageStorageKey: true)));
await tester.drag(find.byType(ListView), const Offset(0, -1000)); await tester.pump(); expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 1000.0);
key.currentState!.hide(); await tester.pump(); key.currentState!.show(); await tester.pump();
expect( tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 1000.0, reason: 'PageStorageKey lets the new Scrollable find its old offset in the PageStorageBucket and restore it', ); });}00:00 +0: WITHOUT a PageStorageKey, scroll position resets when the list is removed and re-added00:00 +1: WITH a PageStorageKey, scroll position survives removal and re-add00:00 +2: All tests passed!(trimmed from the real flutter test test/lessons/widgets__keys/ run on this Mac)
This is exactly the “remember which tab’s list was scrolled where” behavior a TabBarView gives you almost for free — its own children each get a stable storage identity, which is why switching tabs and switching back restores your scroll position without you writing any of the plumbing above by hand.
When NOT to use keys
Section titled “When NOT to use keys”Keys solve one specific problem — position-based matching giving the wrong element an existing widget’s data. They are not a general-purpose fix for “something isn’t updating”:
- A single, non-list widget almost never needs a key. There is only one position for it to be matched at; ambiguity does not exist.
- A list of
StatelessWidgets rarely needs keys. There’s noStateto misplace — proven throughout this lesson, the whole bug is about state following the wrong element. - Don’t reach for
GlobalKeyto “read a value from a distant widget.” That’s whatInheritedWidget/Provider/Riverpod are for (see the State Management module) —GlobalKeyis for reaching a specific widget’sState/context/render object, not for general data flow, and it is expensive: everyGlobalKeyforces Flutter to maintain a global registry entry and prevents certain tree optimizations. - Don’t add a key “just in case.” An unnecessary key can itself force unnecessary element/
Staterecreation if it changes when it shouldn’t (e.g. a key derived from something that changes more often than the actual identity does).
Mental Model
Section titled “Mental Model”A key is a name tag at a coat check, not a queue number. Without one, the attendant (Flutter’s reconciler) hands back coats by slot number — “whatever’s hanging in hook 3 goes to whoever is now standing at position 3” — which is fine until people swap places in line. A name tag (ValueKey) lets the attendant match by whose coat it actually is, regardless of where in line you’re standing now. UniqueKey() is tearing off the old tag and stapling on a brand-new, never-seen-before name — deliberately telling the attendant “I know this looks like the same coat, treat it as a completely different one.” GlobalKey is a master key that works at any coat check in the building, not just this one line — powerful, and exactly why the building only issues one per coat.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| No key | Simplest; correct for stateless lists or lists that never reorder/filter | Silently misplaces state in stateful lists that DO reorder — proven above |
ValueKey/ObjectKey | Correct identity tracking through reorders/filters — proven above | Needs a genuinely stable, unique-within-the-list value to key on |
UniqueKey() | Deliberate, on-demand reset of state/render objects — proven above | Every use forces a full teardown/rebuild of that subtree — not free |
GlobalKey | Only way to reach a specific widget’s State/context from anywhere | One widget at a time (proven crash above); registry overhead; easy to misuse for data flow that should be InheritedWidget/Provider instead |
PageStorageKey | Survives full removal/re-add, not just staying mounted — proven above | Only useful for the specific “restore scroll (or other storable) position after teardown” problem |
Common Misconceptions
Section titled “Common Misconceptions”- “Keys are only for ListView performance.” Proven above: the bug they fix is about correctness (state ending up attached to the wrong data), not raw performance — the checkbox test shows a real, user-visible wrong-answer bug, not just wasted work.
- “Any key fixes the reorder problem, as long as there IS a key.” Not proven — and not true: a key derived from list index (
ValueKey(index)) is functionally identical to no key at all for a reorder, since the index doesn’t move with the data. The key above usesValueKey(label)— the actual data identity — deliberately. - “GlobalKey is just a more powerful ValueKey.” Proven above to have a hard constraint
ValueKeydoes not: oneGlobalKeycan only be attached to one widget at a time, crashing hard (not silently) if violated. - “PageStorageKey is the same as just keeping the widget alive.” They solve different problems:
AutomaticKeepAliveClientMixin/IndexedStackavoid the teardown entirely (theStatenever dies);PageStorageKey— proven above — lets a widget that genuinely IS torn down and rebuilt recover specific stored values afterward.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Flutter code often either omits keys entirely from a reorderable/filterable list (having seen far more simple, static list examples in training data) or, in an over-correction, sprinkles keys everywhere including places they add cost for no benefit — most commonly ValueKey(index) on a list that DOES reorder, which looks like a fix but proven above to not actually be one.
// @expect-error compiles clean — ValueKey(index) does not follow reordered data, proven above with ValueKey(label) insteadclass TaskList extends StatelessWidget { const TaskList({super.key, required this.tasks}); final List<String> tasks;
@override Widget build(BuildContext context) { return Column( children: [ for (var i = 0; i < tasks.length; i++) TaskTile(key: ValueKey(i), title: tasks[i]), ], ); }}Before trusting generated list code, check:
- Does this list ever reorder, filter, insert, or delete in the middle — not just append at the end? If so, do the items hold their own
State(checkboxes, text controllers, animations)? If both are true, it needs identity-based keys. - Is the key derived from position (
ValueKey(index)) or from data identity (ValueKey(item.id))? Only the latter actually survives a reorder, proven above. - Is a
GlobalKeybeing created fresh inside abuild()method or a loop? That’s a near-certain path to the duplicate-key crash proven above, since every rebuild (or every loop iteration) mints a brand-new key that no longer matches the one Flutter already has registered — or worse, two loop iterations mint colliding keys in the same frame. - Does a scrollable that gets torn down and rebuilt (tab content, a conditionally-shown list) need its scroll position preserved? If so, is it missing a
PageStorageKey?
🐛 Spot the bug
class ReorderableNotes extends StatefulWidget {const ReorderableNotes({super.key, required this.notes});final List<String> notes;
@overrideState<ReorderableNotes> createState() => _ReorderableNotesState();}
class _ReorderableNotesState extends State<ReorderableNotes> {@overrideWidget build(BuildContext context) { return ReorderableListView( onReorder: (oldIndex, newIndex) {}, children: [ for (var i = 0; i < widget.notes.length; i++) NoteCard(key: ValueKey(i), note: widget.notes[i]), ], );}}Show the answer
ValueKey(i) keys by position (the loop index), not by the note’s actual identity — for a ReorderableListView, whose entire purpose is letting items change position, this is functionally the same bug as having no key at all: after a drag-reorder, each NoteCard’s State (any local edit-mode flag, animation, or text controller) stays attached to the OLD index, not the note that’s actually there now. The fix: key by the note’s own stable identity — ValueKey(widget.notes[i].id) if notes have ids, or at minimum ValueKey(widget.notes[i]) if the note text itself is guaranteed unique — exactly the ValueKey(label)-not-ValueKey(index) distinction proven in this lesson’s reorder test.