The element tree, keys and State identity

Flutter · Advanced · 16 min

Bugs like state jumping to the wrong list item or text fields losing input come from not understanding element identity.

Widgets are short-lived configurations. Behind them Flutter keeps a long-lived element tree: each widget in the tree is backed by an Element, and stateful elements own their State object.

On rebuild, Flutter walks the old elements and the new widgets side by side. For each position it asks Widget.canUpdate(old, new): same runtime type and same key. If true, the element is reused and updated with the new widget — the State survives and didUpdateWidget runs. If false, the old element (and its State) is disposed and a new one is created.

Without keys, children of a Column or ListView are matched by position. Remove the first item of a list of stateful tiles and the State of the old first tile is now attached to what was the second item — checkboxes, text and animations appear to jump.

Keys fix identity:

  • ValueKey(todo.id) — identity from a stable value; the usual choice for list items.
  • ObjectKey(obj) — identity of an object instance.
  • UniqueKey() — always new, forcing a fresh State every build (useful to reset a widget deliberately).
  • GlobalKey — unique across the whole app; lets an element move between parents keeping its State, and gives access to currentState (as with forms). It's relatively expensive; use sparingly.

Keys only matter among siblings (except GlobalKey). Also note that wrapping a widget in a new parent conditionally (e.g. if (x) Padding(child: w) else w) changes the tree shape and recreates the State.

Example

import 'package:flutter/material.dart';
class Counter extends StatefulWidget {
  const Counter({super.key, required this.label});
  final String label;
  @override State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
  int count = 0;
  @override Widget build(BuildContext context) =>
      TextButton(onPressed: () => setState(() => count++), child: Text('${widget.label}: $count'));
}
class Board extends StatefulWidget {
  const Board({super.key});
  @override State<Board> createState() => _BoardState();
}
class _BoardState extends State<Board> {
  List<String> names = ['A', 'B', 'C'];
  @override
  Widget build(BuildContext context) => Column(children: [
        // Without the key, removing 'A' would leave A's count showing next to B.
        for (final n in names) Counter(key: ValueKey(n), label: n),
        TextButton(onPressed: () => setState(() => names = names.sublist(1)),
            child: const Text('Remove first')),
      ]);
}

Remember

  • Elements (and State) are reused when runtime type and key match
  • Unkeyed siblings are matched by position
  • Use ValueKey for list items; GlobalKey sparingly

Watch out for

  • Using UniqueKey() in a list item builder, which recreates every item's State on every rebuild.

Check yourself

  1. Two widgets at the same position have the same type and key across rebuilds. What happens to the StatefulWidget's State?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: api.flutter.dev/flutter/foundation/Key-class.html