Chuyển đến nội dung chính

Keys in Flutter: the one rule that explains every case

There is a specific bug that teaches everyone about keys. You have a list of stateful rows — each with a checkbox, or an expansion tile, or a text field. You delete the second row. The row disappears correctly, but the checkmark that belonged to it is now sitting on a different row. Nothing about your data is wrong. Reload the page and everything is fine.

That bug is not a Flutter defect. It is the framework doing exactly what it was told, and the fix is one word long. But the fix only sticks if you understand the rule underneath it, because the same rule explains why keys sometimes do nothing at all, why GlobalKey is expensive, and why a PageStorageKey is not really a key in the same sense.

Three trees, and the one that holds your state

Flutter maintains three parallel structures. The widget tree is your build output: immutable configuration objects, thrown away and recreated constantly. The render tree does layout and painting. Between them sits the element tree, and that is the one that matters here, because an Element is long-lived and — for a StatefulWidget — it is what owns the State object.

When a rebuild happens, Flutter walks the old element tree alongside the new widget tree and, for each position, asks one question:

static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      && oldWidget.key == newWidget.key;
}

That is the whole mechanism. If the answer is yes, the existing element (and its State, and its render object) is kept and handed the new widget. If the answer is no, the old element is deactivated and a fresh one is inflated.

Notice what the comparison does not include: any of your data. Two TodoRow widgets with completely different todos are interchangeable as far as canUpdate is concerned, provided both have key == null. And notice that the comparison is made per position in the child list. Child zero is compared to child zero.

Which gives the rule, and it is the only rule:

Flutter matches children by position and type. A key overrides “by position” with “by identity.”

Everything else is a consequence.

Why the bug happens, in slow motion

Column(
  children: [
    for (final todo in todos) TodoRow(todo: todo),   // no keys
  ],
)

Before deletion the element tree is [TodoRow#0, TodoRow#1, TodoRow#2], holding state [unchecked, checked, unchecked]. You remove todos[1] and rebuild. The new widget list has two entries. Flutter compares position 0 to position 0 — same type, both keys null, canUpdate is true, keep element #0 and give it todos[0]. Position 1 to position 1 — same type, keys null, true — so element #1, which is holding the checked state that belonged to the deleted row, is kept and handed todos[2]. Element #2 is disposed.

Result: the right rows render with the right text, because text comes from the widget. The wrong rows carry the state, because state comes from the element. Add a key and position stops being the matcher:

Column(
  children: [
    for (final todo in todos) TodoRow(key: ValueKey(todo.id), todo: todo),
  ],
)

Now canUpdate compares ValueKey('b') to ValueKey('c') at position 1 and says no. Flutter then searches the old children for an element with a matching key, finds element #2, and moves it. State follows identity.

When you need a key, and when you do not

Skip the key when:

  • The children are stateless all the way down. No State, no AnimationController, no scroll position, nothing to move to the wrong place. A list of Text widgets never needs keys.
  • The list never reorders, and items are only ever appended at the end. Position-matching is correct in that case.
  • You are keying the outside of a ListView.builder. Its children already get implicit keys from their index for the purposes of the sliver child delegate; what you need is a key on the item widget, not on the list.

Use a key when any of these is true:

  • Children are stateful and the collection can be reordered, filtered, or have items removed from anywhere but the end.
  • You are swapping between two widgets of the same type and want a fresh State — a UniqueKey forces the old element to be discarded.
  • You are animating items in and out with AnimatedList, AnimatedSwitcher or an implicit animation that needs to tell “the same widget, changed” apart from “a different widget.”

The AnimatedSwitcher case catches people:

AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: Text('$counter', key: ValueKey(counter)),   // without the key: no animation
)

Both children are Text. Without a key, canUpdate is true, the element is reused, and AnimatedSwitcher concludes nothing changed. The key is what makes the change visible to the framework.

Choosing between the key types

TypeEquality based onReach for it when
ValueKey<T>A value you supply (==)You have a stable id: ValueKey(todo.id)
ObjectKeyObject identity of what you passThe model has no id but instances are stable
UniqueKeyNothing — never equal to anythingYou want to force a rebuild-from-scratch
GlobalKeyIdentity, but globally unique in the whole appYou must reach an element or state from outside
PageStorageKeyA value, but used for scroll-position storagePreserving scroll offset across navigation

Two traps in that table.

ValueKey on the wrong value. ValueKey(index) is the most common mistake, because the index is exactly the positional information you were trying to escape. Delete an item and every subsequent index shifts, so the keys shift with it and you are back to matching by position. Key on something intrinsic to the item — a database id, a UUID, a filename.

UniqueKey in a build method. A UniqueKey created during build is different on every rebuild, so canUpdate is always false, so the element and all its state and its subtree are destroyed and rebuilt every single frame. This produces a widget that visibly resets, animations that never finish, and a real performance cost. UniqueKey belongs in a field, or in a deliberate “reset this form” action.

GlobalKey costs more than it looks

A GlobalKey gives you key.currentState, key.currentContext and key.currentWidget from anywhere. The standard use is a Form:

final _formKey = GlobalKey<FormState>();          // a field, not a local

// ...
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save();
}

That is legitimate. What is not free:

  • The framework maintains a global registry from key to element, checked and updated on every mount and unmount.
  • Moving a widget with a GlobalKey to a new position triggers a full deactivate/reactivate cycle for that subtree — a global tree search rather than a local sibling comparison.
  • Two widgets with the same GlobalKey mounted at once is an error, and it happens easily when a GlobalKey is created inside build and the widget appears twice.

Before reaching for one, check whether you actually needed to reach into a subtree, or whether the state belongs one level up. Most GlobalKey usage that is not a Form or a Scaffold/Navigator handle is a state-placement problem in disguise — lift the state, or pass a callback down.

PageStorageKey is a different animal

ListView(
  key: const PageStorageKey<String>('feed'),
  children: [ /* ... */ ],
)

This is a Key, so it participates in canUpdate, but its real job is to name a slot in PageStorage where the scroll offset is written. That is what makes a tab’s scroll position survive switching away and back, or a list restore its offset after a Navigator.push and pop.

Two things follow. The string must be stable across rebuilds and unique among sibling scrollables — two tabs sharing 'feed' will share a scroll offset, which looks like a haunting. And a PageStorageKey only works where a PageStorage exists above it, which MaterialApp and Navigator provide by default.

Debugging: how to tell it is a key problem

The symptom pattern is specific enough to diagnose from behaviour alone. Suspect keys when the data is right and something attached to the data is wrong: text correct, checkbox wrong; the right item removed but the wrong row animating out; a text field keeping its content after you switched which record is being edited; a video keeping playing after you swapped the item.

Confirm it in one minute:

@override
void initState() {
  super.initState();
  debugPrint('initState for ${widget.todo.id} on $hashCode');
}

@override
void didUpdateWidget(TodoRow old) {
  super.didUpdateWidget(old);
  debugPrint('${old.todo.id} -> ${widget.todo.id} on $hashCode');
}

If you see didUpdateWidget reporting a change of id on the same hashCode, an element is being recycled across two different logical items. That is the bug, and a proper ValueKey is the fix. The Flutter Inspector shows the same thing visually — select a row before and after the mutation and watch whether the element identity moves.

FAQ

Where do I put the key — on the item, or on something inside it?

On the outermost widget returned for that item, at the level where siblings are compared. A key on a child inside the row does not help, because the mismatch already happened one level up.

Does adding keys everywhere hurt performance?

ValueKey and ObjectKey are cheap: one extra == during reconciliation. Keys on a large list can actually be faster on reorder, since elements move instead of rebuilding. GlobalKey is the one with real overhead.

Why did adding a key not fix my problem?

Usually the key value is not stable — ValueKey(index), ValueKey(DateTime.now()), or a key built from a field that changes when the item is edited. Print the keys across the mutation and check they identify the same logical item before and after.

Are keys needed inside ListView.builder?

Yes, for the same reasons, if the items are stateful and the collection mutates. The builder’s index is positional; it does not give your item widget an identity.

What is Key versus LocalKey?

Key is the base type. LocalKey is the branch that only has to be unique among siblings — ValueKey, ObjectKey, UniqueKey, PageStorageKey all extend it. GlobalKey is the other branch, unique across the entire app.


The reconciliation behaviour described here is Widget.canUpdate and the element update logic in the Flutter framework, linked above. The guidance on when a GlobalKey signals misplaced state, and the debugging recipe, are my own. Check the API docs for the SDK you ship — key types are stable, but the widgets around them are not.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...