Performance Optimization

Controlling Widget Rebuilds with Keys

15 min Lesson 3 of 12

Controlling Widget Rebuilds with Keys

Flutter's reconciliation algorithm relies on two pieces of information to decide whether an existing Element can be reused for an incoming widget: the widget's runtime type and its key. When you omit a key, Flutter uses position in the widget tree as the implicit identity. This works perfectly for stateless widgets, but breaks down the moment stateful or expensive widgets are reordered, inserted, or removed — because the element subtree that holds live state gets matched to the wrong widget configuration.

Note: A Key does not change how a widget renders. It is purely an identity token that Flutter uses during the diffing phase to decide whether to reuse, dispose, or create fresh elements.

How Flutter Matches Widgets to Elements

On every build() call, Flutter walks both the old and new widget subtrees in parallel. For each position it checks:

  • If the new widget's runtimeType and key match the existing element, Flutter calls update() — the element and its state are preserved.
  • If they do not match, Flutter deactivates the old element (potentially losing state) and inflates a new one.
  • When a key is present, Flutter can also search siblings for a match rather than relying on position alone.

The Four Key Types

ValueKey

Uses a single value — typically a String, int, or enum — as the identity. Two ValueKeys are equal when their values are equal. Use this when each item in a list has a unique, stable identifier such as a database ID or a slug.

// Reorderable to-do list — each tile keeps its checkbox state
// because the key follows the item, not the position.
ListView(
  children: todos.map((todo) {
    return CheckboxListTile(
      key: ValueKey<int>(todo.id),
      value: todo.isDone,
      onChanged: (v) => setState(() => todo.isDone = v!),
      title: Text(todo.title),
    );
  }).toList(),
)

ObjectKey

Uses an entire object's identity (via identical()) rather than its equality. Use this when two objects might have the same field values but are genuinely different instances — for example, two Contact objects with the same name but different references.

// Two contacts may share a name; use object identity to distinguish them.
Column(
  children: contacts.map((contact) {
    return ContactCard(
      key: ObjectKey(contact),
      contact: contact,
    );
  }).toList(),
)

UniqueKey

Generates a key that is never equal to any other key, including another UniqueKey(). Because it is unique per instance, assigning a UniqueKey in build() forces a full teardown and rebuild on every render — the element is always discarded and recreated. This is intentional in rare scenarios (e.g., dismissing and reinitialising a widget), but destructive if used carelessly.

Warning: Never create a UniqueKey() inside a build() method unless you deliberately want the widget to lose all its state on every rebuild. Store the key as a field in your State class if you need to trigger a one-time reset.

GlobalKey

Provides a handle to an element (and its associated State or BuildContext) that can be accessed from anywhere in the widget tree, even across routes. Common uses are accessing form state, triggering animations, or measuring a widget's size at runtime. GlobalKey is powerful but expensive — each key is registered in a global lookup table, so creating many of them degrades performance.

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  // Stored as a field — NOT recreated on every build().
  final _formKey = GlobalKey<FormState>();

  void _submit() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      // proceed with login
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            validator: (v) => v!.isEmpty ? 'Required' : null,
          ),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Log In'),
          ),
        ],
      ),
    );
  }
}

The Classic Keys Bug: Stateful List Items

The most instructive failure case is a reorderable list of StatefulWidget items with no keys. When the first and second items swap positions, Flutter sees the same widget types at each position, calls update() on the existing elements, and updates the configuration — but the state objects stay in place. The result: the wrong state is attached to the wrong item. Adding ValueKey (or ObjectKey) keyed to the item's identity fixes this immediately because Flutter can match elements by key across the position change.

Choosing the Right Key

  • List/grid items with stable IDsValueKey(item.id)
  • Items identified by object referenceObjectKey(item)
  • Force full reset once (store in State, flip to new UniqueKey on demand) → UniqueKey()
  • Cross-tree access to State or BuildContextGlobalKey (use sparingly)
  • Stateless widgets in a stable list → no key needed
Tip: As a rule of thumb — if your widget carries state (animations, text controllers, scroll position, checkbox values) and it can appear in a collection that may be reordered or filtered, always supply a ValueKey or ObjectKey. The performance cost is negligible; the correctness benefit is significant.

Summary

Keys give Flutter explicit control over element identity during reconciliation. ValueKey matches by a logical value, ObjectKey matches by reference identity, UniqueKey always forces recreation, and GlobalKey exposes a widget's internals across the tree. Using the right key in the right place prevents subtle state-misattribution bugs and gives you fine-grained control over when Flutter preserves or discards widget subtrees.