← Back to blog
Flutter Internals · Part 2 of 7
September 21, 202611 min read

Keys in Flutter: ValueKey, GlobalKey & When You Actually Need Them

FlutterDartInternals

Keys in Flutter

This is Part 2 of the Flutter Internals series. In Part 1 we learned the rule that governs everything: an Element is reused for a new widget only if canUpdate returns true — i.e. same runtimeType and same key. Keys are the key half of that rule. This post is about wielding them.

Keys are simultaneously one of the most asked-about and most misused Flutter topics. Beginners sprinkle them everywhere "just in case"; others hit a baffling bug where list items keep the wrong state and have no idea why. Both problems vanish once you internalize a single idea: a key is how you tell Flutter an Element's identity, so reconciliation follows the thing and not its position. Let's make it concrete.

Builds directly on Part 1's canUpdate/reconciliation. Flutter 3.38 / Dart 3.12.


First: most widgets don't need keys

Let's defuse the over-keying instinct immediately.

You usually don't need a key. If a widget's position in its parent is stable and its siblings aren't the same type competing for the same Element, reconciliation already does the right thing by runtimeType and position. Adding keys there is noise.

Keys earn their keep in exactly one situation, which we'll build up to: a collection of widgets of the same type whose order or membership can change, where each one carries state (or an expensive subtree) that must follow the right item.


The bug that makes keys click

Here's the canonical example. Three colored boxes, each a StatefulWidget holding its own randomly-chosen color in State. A button removes the first one.

class ColorBox extends StatefulWidget {
  const ColorBox({super.key}); // ← note: no value key yet
  @override
  State<ColorBox> createState() => _ColorBoxState();
}
class _ColorBoxState extends State<ColorBox> {
  // Color is chosen ONCE, in the State — so it's "identity" we can track.
  final Color color = Colors.primaries[Random().nextInt(Colors.primaries.length)];
  @override
  Widget build(BuildContext context) => Container(width: 60, height: 60, color: color);
}

// In the parent:
Row(children: boxes) // List<ColorBox>, remove boxes.first on tap

Remove the first box and watch: the colors are wrong. You deleted the red box, but now the green box vanished and the others shifted color. What happened?

The reconciliation walks children by position. After removal there are 2 widgets and 3 old Elements. Flutter pairs new-widget[0] with old-Element[0], new-widget[1] with old-Element[1]. Since all are ColorBox with the same (null) key, canUpdate says "reuse!" — so the State (and its color) stays attached to the position, not the box you removed. The last Element is dropped. Visually, you removed the last box's state, not the first.

The Elements (holding the colors) matched by slot, because there was no key to say which box is which.


The fix: give each item an identity

Attach a ValueKey tied to the item's real identity:

ColorBox(key: ValueKey(box.id)) // or ValueKey of a stable unique value

Now reconciliation changes completely. When the list goes from [id1, id2, id3] to [id2, id3]:

With keys, Flutter matches new widgets to old Elements by key, not position. It sees id1 is gone → disposes that Element; id2 and id3 match their existing Elements → those keep their state. The correct box disappears and the rest keep their colors. Identity now follows the key.

That's the whole point of keys in one sentence: they make canUpdate match by identity instead of slot. Everything else is detail.


The key family

Key (abstract)
├── LocalKey          — unique among SIBLINGS only
│   ├── ValueKey<T>   — identity = a value (id, string, enum)
│   ├── ObjectKey     — identity = an object's identity (==/hashCode)
│   └── UniqueKey     — identity = itself; a brand-new identity every time
└── GlobalKey         — unique across the ENTIRE app; also grants access to state/context

LocalKey — the common case (sibling identity)

A LocalKey only needs to be unique among siblings in the same parent. Three variants:

| Key | Identity is… | Use when | | --- | --- | --- | | ValueKey(v) | the value v (compared by ==) | You have a stable scalar id — a database id, a string, an enum | | ObjectKey(obj) | the object's identity | The item has no scalar id but the object is stable | | UniqueKey() | a fresh, unrepeatable identity | You want to force a brand-new Element (reset state) every build |

ListView(children: [
  for (final todo in todos)
    TodoTile(key: ValueKey(todo.id), todo: todo), // stable id → ValueKey
]);

UniqueKey is the odd one — it creates a new identity on every build, so canUpdate always fails and the widget's Element/state is rebuilt from scratch every time. That's occasionally what you want (force a reset/re-animate), but using it in a list is a bug — it defeats reuse entirely.

GlobalKey — app-wide identity + access

A GlobalKey is unique across the whole app and does two special things LocalKey can't:

  1. Access an element's state/context from anywhere. The classic Form:
final _formKey = GlobalKey<FormState>();

Form(key: _formKey, child: ...);

// Elsewhere, reach into the Form's State directly:
if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); }
  1. Move a widget across the tree while preserving its state. Because a GlobalKey identifies an Element globally, Flutter can re-parent that Element (and its state + render object) to a new location instead of rebuilding it — e.g. moving a playing video between two layouts without restarting it.

Use GlobalKey sparingly. It's heavier than a LocalKey (the framework maintains a global registry), and it must be unique — reusing the same GlobalKey in two places in the tree at once throws. Reach for it only when you genuinely need cross-tree access or re-parenting; for list identity, a LocalKey is right.


Where to put the key (a subtle, critical rule)

The key goes on the widget whose Element you want tracked — usually the top of the subtree that should preserve identity, placed as a direct child of the changing collection.

// ✅ Key on the item widgets that are siblings in the list:
Column(children: [
  for (final item in items) ItemCard(key: ValueKey(item.id), item: item),
]);

// ❌ Key buried deep inside ItemCard's build won't help the list reconcile —
//    the list reconciles ItemCard at the sibling level.

Rule: put the key on the widget at the level where reconciliation compares siblings — i.e. the elements directly in the list/children that can reorder. A key three layers deep doesn't help the list match items.


When you need keys — the checklist

You need a key when all of these are true:

  1. You have a list/collection of widgets,
  2. the widgets are the same type (so runtimeType alone can't tell them apart),
  3. they hold state (or an expensive/animated subtree), and
  4. their order or membership can change (insert, remove, reorder, filter).

Miss any one and you probably don't need a key:

  • All-StatelessWidget list that never reorders → no key needed (though ValueKey is harmless and can help diffs).
  • A fixed, never-reordered layout → no key needed.
  • Two different widget types swapping → runtimeType already distinguishes them.
// Needs keys: stateful, reorderable, same type:
ReorderableListView(children: [
  for (final t in tasks) TaskTile(key: ValueKey(t.id), task: t),
]);

The tell: if reordering, deleting, or filtering a list makes state "stick to the wrong row," you're missing keys. If you have no state and no reordering, you're probably not.


A second classic: same-type swap resetting state

Even outside lists, keys fix "I swapped two same-type widgets and state leaked":

// Toggling 'first' swaps two TextFields. Without keys, the Element (and its
// controller/scroll/cursor state) is reused by position — text appears to "stay put".
Widget build(BuildContext context) {
  final a = TextField(key: const ValueKey('a'));
  final b = TextField(key: const ValueKey('b'));
  return Row(children: first ? [a, b] : [b, a]); // keys make them follow correctly
}

Without the keys, both are TextField at positions 0 and 1, so swapping does nothing meaningful to the Elements. With keys, Flutter matches by identity and the fields truly swap.


Practice Challenges

Challenge 1 — Diagnose. A list of stateful expandable tiles loses its "expanded" state on the wrong rows after you delete one. What's missing and why?

Show solution

Keys. Without them, reconciliation matches tiles by position, so the State (expanded flag) stays glued to the slot, not the item. Add key: ValueKey(tile.id) so Flutter matches by identity and the correct tile's state is removed. (Part 1 canUpdate.)

Challenge 2 — Pick the key. Choose the right key type: (a) items have a unique id int, (b) items are objects with no id but stable identity, (c) you want a widget to fully reset every rebuild.

Show solution

(a) ValueKey(item.id). (b) ObjectKey(item). (c) UniqueKey() — a fresh identity each build forces a rebuild. (a)/(b) are LocalKeys for sibling identity.

Challenge 3 — Local or Global? You need to call validate() on a Form from a button elsewhere. Which key, and why not a ValueKey?

Show solution

A GlobalKey<FormState> — it gives access to the element's State (_formKey.currentState) from anywhere, which a LocalKey can't. LocalKey only affects reconciliation identity among siblings; it provides no state access.

Challenge 4 — Spot the misuse. A dev puts key: UniqueKey() on every item in a long ListView.builder "to be safe." What goes wrong?

Show solution

UniqueKey() generates a new identity every build, so canUpdate always fails and every item's Element/state is rebuilt from scratch each frame — destroying reuse, resetting state, and tanking performance. Use a stable ValueKey(item.id) instead.

Challenge 5 — Placement. You add ValueKey(item.id) deep inside the item widget's build but the list still misbehaves. Why?

Show solution

The key must be on the widget at the sibling level the list reconciles — i.e. the direct child of the children list. A key buried deeper doesn't influence how the list matches its top-level item widgets. Move the key to the item widget itself.


Questions to test yourself

Q1 (basic). What does a key actually control, in terms of Part 1?

Show answer

The key half of canUpdate(old, new) (which checks runtimeType and key). It makes reconciliation match an Element to a widget by identity rather than position, deciding which Element (and its state) is reused.

Q2 (basic). Do most widgets need keys?

Show answer

No. With stable structure and distinct sibling types, reconciliation already works by type and position. Keys are needed mainly for same-type, stateful widgets in a collection that can reorder or change membership.

Q3 (intermediate). Explain the "reorder resets state" bug and the fix.

Show answer

Without keys, Flutter matches widgets to existing Elements by position, so State stays attached to the slot — after a reorder/removal the wrong rows keep state. Adding a ValueKey tied to each item's identity makes Flutter match by key, so state follows the correct item.

Q4 (intermediate). LocalKey vs GlobalKey — scope and extra power?

Show answer

A LocalKey (ValueKey/ObjectKey/UniqueKey) is unique among siblings and only affects reconciliation. A GlobalKey is unique across the whole app, additionally lets you access an element's state/context (currentState/currentContext) and re-parent a widget across the tree preserving its state.

Q5 (advanced). Why is UniqueKey() dangerous in a list but useful elsewhere?

Show answer

It produces a new identity every build, so canUpdate always fails — in a list that means every item is rebuilt from scratch each frame (no reuse, lost state, poor perf). Elsewhere that same property is useful to deliberately force a fresh Element (reset a widget, restart an animation) when you want a guaranteed rebuild.

Q6 (advanced). How can a GlobalKey move a widget across the tree without losing state?

Show answer

A GlobalKey identifies a specific Element globally, so when that keyed widget appears in a new location, Flutter can re-parent the existing Element (with its State and RenderObject) instead of building a new one — preserving state (e.g. a playing video) across the move. It's powerful but heavier, so use it only when needed.


Wrapping up

  • A key is the key half of canUpdate — it makes reconciliation match Elements by identity, not position.
  • Most widgets don't need keys. You need one for same-type, stateful widgets in a collection that can reorder/insert/remove.
  • LocalKey (sibling-unique): ValueKey (stable scalar), ObjectKey (stable object), UniqueKey (force a fresh Element — never in lists).
  • GlobalKey (app-unique): access an element's state/context (Form validation) and re-parent a widget preserving state — use sparingly.
  • Put the key at the sibling level the collection reconciles, on the item widget itself.

In Part 3 we zoom into the object that is the Element — the one you pass around constantly and misuse just as often: BuildContext — what it really is, why "context above/below" matters, and the errors it causes.