Keys & Widget Identity
Understanding Widget Identity in Flutter
In Flutter, every widget in the tree has an identity -- a way for the framework to determine whether a widget is the "same" widget from one build to the next. By default, Flutter identifies widgets by their type and position in the widget tree. But when widgets move, reorder, or swap positions, this default mechanism breaks down. That’s where Keys come in.
Keys provide an explicit identity to widgets, telling Flutter: "This specific widget instance should be matched with this specific element, regardless of its position in the tree." Understanding keys is essential for building correct, performant Flutter applications -- especially when working with lists, animations, and form state.
How Flutter’s Reconciliation Works
When Flutter rebuilds the widget tree, it runs a diffing algorithm to decide which elements to update, create, or dispose. Understanding this process explains why keys matter:
The Diffing Problem Without Keys
// Imagine a list of colored boxes with internal state (a counter)
class ColorBox extends StatefulWidget {
final Color color;
const ColorBox({required this.color, super.key});
@override
State<ColorBox> createState() => _ColorBoxState();
}
class _ColorBoxState extends State<ColorBox> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _counter++),
child: Container(
color: widget.color,
padding: const EdgeInsets.all(16),
child: Text('Taps: \$_counter',
style: const TextStyle(color: Colors.white)),
),
);
}
}
// Parent that can swap the order
class SwapDemo extends StatefulWidget {
const SwapDemo({super.key});
@override
State<SwapDemo> createState() => _SwapDemoState();
}
class _SwapDemoState extends State<SwapDemo> {
bool _swapped = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Without keys, swapping reuses elements by position
// The state (_counter) stays at position, not with the color!
if (!_swapped) ...[
ColorBox(color: Colors.red), // Position 0
ColorBox(color: Colors.blue), // Position 1
] else ...[
ColorBox(color: Colors.blue), // Position 0
ColorBox(color: Colors.red), // Position 1
],
ElevatedButton(
onPressed: () => setState(() => _swapped = !_swapped),
child: const Text('Swap'),
),
],
);
}
}
Types of Keys
Flutter provides several key types, each designed for specific use cases. Choosing the right key type is critical for correct behavior.
ValueKey
ValueKey identifies a widget using a single value. It’s the most commonly used key type and is ideal when each item has a unique, stable identifier like an ID, email, or name.
ValueKey Examples
// Using ValueKey with a unique identifier
ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return UserTile(
key: ValueKey(user.id), // Stable unique ID
user: user,
);
},
)
// ValueKey uses == for comparison
ValueKey(42) == ValueKey(42) // true
ValueKey('abc') == ValueKey('abc') // true
ValueKey(42) == ValueKey('42') // false (different types)
// Common uses
TextField(key: ValueKey('email-field')),
Dismissible(key: ValueKey(item.id), child: ...),
AnimatedSwitcher(child: Text(key: ValueKey(text), text)),
ObjectKey
ObjectKey uses an object’s identity (the identical() check) rather than equality (==). This is useful when two objects might be equal by value but are distinct instances that should be treated differently.
ObjectKey vs ValueKey
class Product {
final String name;
final double price;
Product(this.name, this.price);
@override
bool operator ==(Object other) =>
other is Product && other.name == name && other.price == price;
@override
int get hashCode => Object.hash(name, price);
}
final product1 = Product('Widget', 9.99);
final product2 = Product('Widget', 9.99);
// ValueKey compares by value (==)
ValueKey(product1) == ValueKey(product2); // true -- same value
// ObjectKey compares by identity (identical())
ObjectKey(product1) == ObjectKey(product2); // false -- different instances
// Use ObjectKey when you have duplicate values but distinct instances
// that need their own state
ListView(
children: cartItems.map((item) =>
CartItemTile(key: ObjectKey(item), item: item)
).toList(),
)
UniqueKey
UniqueKey generates a new unique identity every time it’s created. It’s useful when you want to force a widget to be treated as a completely new widget, discarding any previous state.
UniqueKey for Forcing Rebuilds
// Force a widget to reset its state completely
class ResetableForm extends StatefulWidget {
const ResetableForm({super.key});
@override
State<ResetableForm> createState() => _ResetableFormState();
}
class _ResetableFormState extends State<ResetableForm> {
Key _formKey = UniqueKey();
void _resetForm() {
setState(() {
// New UniqueKey = Flutter treats this as an entirely new widget
// All internal state is discarded and recreated
_formKey = UniqueKey();
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
MyComplexForm(key: _formKey), // State resets when key changes
ElevatedButton(
onPressed: _resetForm,
child: const Text('Reset Form'),
),
],
);
}
}
// CAUTION: Never use UniqueKey() directly in build()
// This creates a new key every build, destroying state each frame!
// BAD:
Widget build(BuildContext context) {
return MyWidget(key: UniqueKey()); // State destroyed every rebuild!
}
UniqueKey is a controlled demolition tool. Store it in a variable and only replace it when you intentionally want to destroy and recreate the widget’s state.GlobalKey
GlobalKey is the most powerful -- and most expensive -- key type. It provides access to the widget’s State and BuildContext from anywhere in the widget tree, and allows a widget to move between different parts of the tree while preserving its state.
GlobalKey for Accessing State
class ParentWidget extends StatefulWidget {
const ParentWidget({super.key});
@override
State<ParentWidget> createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State<ParentWidget> {
// Create a GlobalKey to access the child's state
final GlobalKey<_CounterWidgetState> _counterKey = GlobalKey();
void _incrementFromParent() {
// Access child's state directly
_counterKey.currentState?.increment();
}
void _getChildInfo() {
// Access child's context and render box
final context = _counterKey.currentContext;
if (context != null) {
final box = context.findRenderObject() as RenderBox;
final size = box.size;
final position = box.localToGlobal(Offset.zero);
print('Child size: \$size, position: \$position');
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
CounterWidget(key: _counterKey),
ElevatedButton(
onPressed: _incrementFromParent,
child: const Text('Increment from Parent'),
),
],
);
}
}
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
void increment() => setState(() => _count++);
@override
Widget build(BuildContext context) {
return Text('Count: \$_count');
}
}
PageStorageKey
PageStorageKey is a special key used to persist scroll positions and other page-specific state across tab switches or navigation events.
PageStorageKey for Scroll Preservation
// In a TabBarView, each tab's scroll position is automatically
// saved and restored using PageStorageKey
class TabbedListScreen extends StatelessWidget {
const TabbedListScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: [
Tab(text: 'Recent'),
Tab(text: 'Popular'),
Tab(text: 'Favorites'),
],
),
),
body: TabBarView(
children: [
// Each list preserves its scroll position independently
ListView.builder(
key: const PageStorageKey('recent-list'),
itemCount: 100,
itemBuilder: (ctx, i) => ListTile(title: Text('Recent \$i')),
),
ListView.builder(
key: const PageStorageKey('popular-list'),
itemCount: 100,
itemBuilder: (ctx, i) => ListTile(title: Text('Popular \$i')),
),
ListView.builder(
key: const PageStorageKey('favorites-list'),
itemCount: 100,
itemBuilder: (ctx, i) => ListTile(title: Text('Favorite \$i')),
),
],
),
),
);
}
}
When to Use Keys
Not every widget needs a key. Here are the specific scenarios where keys are essential:
1. Reorderable and Dismissible Lists
Keys in ReorderableListView
class TodoList extends StatefulWidget {
const TodoList({super.key});
@override
State<TodoList> createState() => _TodoListState();
}
class _TodoListState extends State<TodoList> {
final List<Todo> _todos = [
Todo(id: 1, title: 'Buy groceries'),
Todo(id: 2, title: 'Walk the dog'),
Todo(id: 3, title: 'Write code'),
];
@override
Widget build(BuildContext context) {
return ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _todos.removeAt(oldIndex);
_todos.insert(newIndex, item);
});
},
children: _todos.map((todo) =>
Dismissible(
// MUST have a key -- required by Dismissible
key: ValueKey(todo.id),
onDismissed: (_) => setState(() => _todos.remove(todo)),
child: ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.isDone,
onChanged: (v) => setState(() => todo.isDone = v!),
),
),
),
).toList(),
);
}
}
2. AnimatedSwitcher Transitions
Keys for Animation Transitions
class AnimatedCounter extends StatelessWidget {
final int count;
const AnimatedCounter({required this.count, super.key});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
// Key tells AnimatedSwitcher when the child has "changed"
// Without a key, same widget type = no animation
child: Text(
'\$count',
key: ValueKey(count), // New key = new child = animation triggers
style: Theme.of(context).textTheme.headlineLarge,
),
);
}
}
3. Preserving State Across Widget Swaps
Fixing the Swap Bug with Keys
class SwapDemoFixed extends StatefulWidget {
const SwapDemoFixed({super.key});
@override
State<SwapDemoFixed> createState() => _SwapDemoFixedState();
}
class _SwapDemoFixedState extends State<SwapDemoFixed> {
bool _swapped = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
if (!_swapped) ...[
// Keys ensure state follows the widget, not the position
ColorBox(key: const ValueKey('red'), color: Colors.red),
ColorBox(key: const ValueKey('blue'), color: Colors.blue),
] else ...[
ColorBox(key: const ValueKey('blue'), color: Colors.blue),
ColorBox(key: const ValueKey('red'), color: Colors.red),
],
ElevatedButton(
onPressed: () => setState(() => _swapped = !_swapped),
child: const Text('Swap'),
),
],
);
}
}
Key Propagation and Scope
Keys are scoped to their parent widget. Two widgets in different parents can have the same key value without conflict. This is important to understand when debugging key-related issues.
Key Scope Example
// These keys don't conflict because they're in different parent Columns
Column(
children: [
Text(key: ValueKey('title'), 'Section A'),
Text(key: ValueKey('body'), 'Content A'),
],
)
Column(
children: [
// Same key values, different parent -- no conflict
Text(key: ValueKey('title'), 'Section B'),
Text(key: ValueKey('body'), 'Content B'),
],
)
// BUT: Duplicate keys in the SAME parent cause errors!
// This throws a runtime error:
Column(
children: [
Text(key: ValueKey('same'), 'First'),
Text(key: ValueKey('same'), 'Second'), // ERROR: duplicate key
],
)
Practical Example: Reorderable Card List with State
Let’s build a complete example that demonstrates why keys are essential -- a reorderable card list where each card has its own expandable state:
Complete Reorderable Card Example
class NoteCard extends StatefulWidget {
final String title;
final String content;
const NoteCard({
required this.title,
required this.content,
super.key,
});
@override
State<NoteCard> createState() => _NoteCardState();
}
class _NoteCardState extends State<NoteCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: [
ListTile(
title: Text(widget.title,
style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: IconButton(
icon: Icon(_isExpanded
? Icons.expand_less
: Icons.expand_more),
onPressed: () =>
setState(() => _isExpanded = !_isExpanded),
),
),
if (_isExpanded)
Padding(
padding: const EdgeInsets.all(16),
child: Text(widget.content),
),
],
),
);
}
}
class NoteListScreen extends StatefulWidget {
const NoteListScreen({super.key});
@override
State<NoteListScreen> createState() => _NoteListScreenState();
}
class _NoteListScreenState extends State<NoteListScreen> {
final List<Map<String, String>> _notes = [
{'id': '1', 'title': 'Flutter Keys', 'content': 'Keys control identity...'},
{'id': '2', 'title': 'State Management', 'content': 'Choose wisely...'},
{'id': '3', 'title': 'Performance', 'content': 'Profile first...'},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My Notes')),
body: ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _notes.removeAt(oldIndex);
_notes.insert(newIndex, item);
});
},
children: _notes.map((note) => NoteCard(
// Key ensures expanded state follows the card during reorder
key: ValueKey(note['id']),
title: note['title']!,
content: note['content']!,
)).toList(),
),
);
}
}
Accessing Widget State with GlobalKey
While GlobalKey should be used sparingly, there are legitimate cases where accessing a child widget’s state is the cleanest solution -- form validation being the most common:
GlobalKey with Form Validation
class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
// GlobalKey for the Form widget
final _formKey = GlobalKey<FormState>();
String _email = '';
String _password = '';
void _submit() {
// Access Form's state to trigger validation
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// Process registration...
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registering \$_email...')),
);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: (v) =>
v != null && v.contains('@') ? null : 'Invalid email',
onSaved: (v) => _email = v ?? '',
),
TextFormField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) =>
v != null && v.length >= 8 ? null : 'Min 8 characters',
onSaved: (v) => _password = v ?? '',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _submit,
child: const Text('Register'),
),
],
),
);
}
}
Summary
- ValueKey -- Use when items have a unique value (ID, name). Most common choice.
- ObjectKey -- Use when you need identity-based comparison, not value-based.
- UniqueKey -- Use to force state reset. Store in a variable, never create in build().
- GlobalKey -- Use sparingly for cross-tree state access (Forms, Scaffolds). Expensive.
- PageStorageKey -- Use to preserve scroll positions across tab switches.
- Add keys to lists that reorder, add/remove items, or need state preservation.
- Keys are scoped to their parent -- duplicates within the same parent cause errors.