Keys in Flutter Explained: ValueKey, ObjectKey, UniqueKey and GlobalKey
Keys in Flutter explained with real bugs: how the element tree matches widgets, when to use ValueKey, ObjectKey, UniqueKey, GlobalKey and PageStorageKey.

Every Flutter widget constructor has an optional key parameter, and most developers pass super.key through without a second thought for months. Then one day they delete the first item in a list and the checkbox from that item jumps to the next row. That’s the day they learn what keys are for.
I’ll go through this as the questions people actually ask me, roughly in the order they ask them.
Why do keys exist at all?
Because widgets are thrown away constantly and something has to decide what to keep.
Flutter has three trees. Widgets are cheap, immutable descriptions. Elements are the long-lived objects that hold your State and connect widgets to render objects. When a parent rebuilds, Flutter walks its new list of child widgets and tries to match each one to an existing element so it can reuse it.
The matching rule is Widget.canUpdate: an element can be updated with a new widget if the runtime type and the key are the same as before. With no keys, the key comparison is null == null, so matching comes down to type and position. The first TodoTile in the new list gets the element (and the State) of the first TodoTile in the old list, whatever data it now holds.
For stateless widgets that’s harmless; they rebuild from the new data anyway. For stateful widgets, the State is now attached to the wrong item.
One detail that matters later: keys are compared only among siblings, the children of the same parent. A ValueKey(1) in one Column has nothing to do with a ValueKey(1) in another.
What does the classic bug look like?
A list of tasks, each with a tile that keeps local state, here an “expanded” flag:
class TaskTile extends StatefulWidget {
const TaskTile({super.key, required this.task});
final Task task;
@override
State<TaskTile> createState() => _TaskTileState();
}
class _TaskTileState extends State<TaskTile> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(widget.task.title),
subtitle: _expanded ? Text(widget.task.notes) : null,
onTap: () => setState(() => _expanded = !_expanded),
);
}
}
// In the parent:
Column(
children: [for (final task in tasks) TaskTile(task: task)],
)
Expand the first task, then delete it. The second task is now at position zero, matches the first element, inherits _expanded = true, and shows up expanded. The last element is the one that gets thrown away. Same story with drag-to-reorder: the titles move, the state stays put.
The fix is one argument:
Column(
children: [
for (final task in tasks) TaskTile(key: ValueKey(task.id), task: task),
],
)
Now Flutter matches by identity, not position. The tile for task 42 keeps its element wherever task 42 goes. ReorderableListView goes further and asserts that every child has a key, because reordering without them can’t work.
Which key should I use?
| Key | Equality based on | Use it when |
|---|---|---|
ValueKey(value) |
== on the value |
You have a stable ID: database ID, slug, enum |
ObjectKey(object) |
identical() on the object |
No ID, but the same object instance represents the item |
UniqueKey() |
Only equal to itself | You want a fresh element every time |
GlobalKey() |
Unique across the whole app | You need the State or context from elsewhere, or to move a subtree |
PageStorageKey(value) |
Like ValueKey |
You want scroll position remembered |
ValueKey covers the vast majority of real cases. If your models have IDs, use them. Don’t use the list index as a key; ValueKey(index) behaves exactly like no key at all, because the index is the position.
ObjectKey is handy when items come from somewhere without IDs and you keep the same instances around. If your models are recreated on every fetch (as most are after JSON parsing), identity changes every time and ObjectKey will rebuild everything. In that case, give your models IDs. If you use code generation for models, I cover that in JSON serialization with freezed.
UniqueKey is a sledgehammer. Creating one inside build means a brand new element every rebuild: state lost, controllers recreated, animations restarted. Occasionally that’s what you want, such as forcing a widget to reset. Usually it’s a bug someone added to “fix” a stale-state issue without understanding it.
Why does my AnimatedSwitcher not animate?
This one’s a favorite. You write:
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: Text('$count'),
)
The count changes, the text changes, and nothing animates. AnimatedSwitcher decides whether it has a new child by comparing type and key, the same rule as above. A Text replaced by another Text with no key looks like the same child, so it just updates in place.
Give it a key that changes when the content does:
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: Text('$count', key: ValueKey(count)),
)
Now each value is a different child, and the old one fades out while the new one fades in. The same trick works for swapping between loading, error and content states that happen to share a widget type. There’s more on transitions like this in Flutter animations that feel native.
When is a GlobalKey the right tool?
Less often than you’d think, but there are good uses.
Forms. GlobalKey<FormState> is the standard way to validate and save a Form from a button outside it:
final _formKey = GlobalKey<FormState>();
// In build:
Form(
key: _formKey,
child: Column(children: [/* TextFormFields */]),
)
// In the submit handler:
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
}
I go deeper into form patterns in better forms in Flutter.
Measuring a widget after layout. If you need the size or position of something, a GlobalKey gives you its context and from there its render box:
final _cardKey = GlobalKey();
void _measure() {
final box = _cardKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final size = box.size;
final topLeft = box.localToGlobal(Offset.zero);
// position a tooltip, start a hero-like animation, etc.
}
Call that after layout, from a callback or a post-frame callback, not during build.
Moving a stateful subtree. A widget with a GlobalKey that’s removed from one parent and inserted under another in the same frame keeps its State. That’s how you can move a playing video between a small and a full-screen layout without restarting it.
What does a GlobalKey cost?
Each one is registered in a global map, so creating and tearing them down isn’t free. Reparenting is more expensive than a normal update, because the subtree is detached and reattached. They also tempt people into reaching into another widget’s State and calling methods on it, which couples widgets in ways that are hard to test. If you find yourself doing someKey.currentState!.refresh(), that logic probably belongs in a controller or state management layer.
And the bug I see most: creating a GlobalKey inside build. A new key each rebuild means a new element each rebuild, with all the state loss of UniqueKey plus the overhead. Store it in a State field, created once.
How do I keep scroll position between tabs?
When you switch tabs in a TabBarView or PageView, off-screen pages are usually disposed. Come back and the list is at the top again.
PageStorageKey fixes the common case. Scrollables save their offset into the nearest PageStorage bucket under their key, and restore it when they’re rebuilt:
TabBarView(
children: [
ListView.builder(
key: const PageStorageKey('inbox'),
itemCount: inbox.length,
itemBuilder: (context, i) => MessageTile(inbox[i]),
),
ListView.builder(
key: const PageStorageKey('archive'),
itemCount: archive.length,
itemBuilder: (context, i) => MessageTile(archive[i]),
),
],
)
MaterialApp already provides a PageStorage bucket for each route, so this works without extra setup. The keys just need to be distinct among the pages. If you need to keep the whole page alive (text in fields, loaded images), look at AutomaticKeepAliveClientMixin instead; PageStorageKey only saves scroll offsets and a few similar values.
So when should I add a key?
My short rule: add a key when a collection of stateful siblings can change order, length or identity. Lists you add to and remove from, reorderable lists, AnimatedSwitcher children, conditional widgets of the same type. Static layouts with a fixed set of children don’t need them.
Takeaways
- Flutter reuses elements by matching runtime type and key among siblings; without keys, that means position.
- Stateful items in dynamic lists need a
ValueKeybuilt from a stable ID, never the index. AnimatedSwitcheronly animates when the child’s type or key changes.- Use
GlobalKeyfor forms, measuring and moving subtrees, and create it once, never inbuild. PageStorageKeyrestores scroll positions across tabs and pages for free.
Keys are one of those topics that feel abstract until the first time a checkbox jumps rows, and then they make complete sense.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).