All I Know about GlobalKey

All I Know about GlobalKey

Many of you know every single Widgets has a constructor parameter called key.

Key is one of the fundamental mechanisms in Flutter to manage Widgets, but do you know everything about Key, especially GlobalKey?

Key tends to be described as an object that uniquely identifies a Widget, but once it comes to GlobalKey, it's not all about that.

In this article, I'm going to describe what I know about GlobalKey as much as possible, especially focusing on how the result of rebuilding will change when we use GlobalKey.

Key in general

Let's start by diving into framework.dart to see how key works when rebuilding.

First, key in general is used to compare oldWidget and newWidget. The typical usage is canUpdate static method below.

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

This method is called to determine "how much" the widget is changed between the previous and current build in Element.updateChild method. So let's take a look at the brief process of Element.updateChild method as well.

Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {

  if (hasSameSuperclass && child.widget == newWidget) {
    // case 1: if the widget is the same object, typically by const constructor
    // nothing happens, meaning children are not rebuilt at all.
    newChild = child;
  } else if (hasSameSuperclass &&
      Widget.canUpdate(child.widget, newWidget)) {
    // case 2: if canUpdate returns true, Element is reused but children are rebuilt.
    if (child.slot != newSlot) {
      updateSlotForChild(child, newSlot);
    }
    child.update(newWidget);
    newChild = child;
  } else {
    // case 3: if canUpdate returns false, old Element is deactivated and new Element is created.
    deactivateChild(child);
    newChild = inflateWidget(newWidget, newSlot);
  }

  return newChild;
}

I've omitted most of the code for brevity leaving the major process of the method. I also added comments to describe the 3 cases of "how much" the widget is changed between the previous and current build. I'll explain the 3 cases in detail below.

Case 1: Widget is the same object

The widgets at the same position before and after rebuilding are the same object. This is the case typically when the widget is created by const constructor.

const Text('Hello, World!');

In this case, existing Element is reused and that's all. Children are not rebuilt at all in this case, so we can say it's the most efficient and performant case.

Case 2: canUpdate returns true

If canUpdate returns true, existing Element is reused but children are rebuilt. This is the second most efficient case because no Element is created and initialized, meaning State or RenderObject is also reused and no additional calculation is needed unless the configuration of the widget is changed.

So, when canUpdate returns true? Let's take a look at the implementation of canUpdate method again.

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

There are 2 comparisons, runtimeType and key.

runtimeType is the type of the widget, such as Text, Container, Button, etc. key is the Key object which is the main target of this article.

For example, the widget coded below would return true.

Container(
  width: 100,
  height: 100,
  color: Colors.red,
);

Because runtimeType is always the same, Container, and key is also the same, null, canUpdate returns true.

build() method is called again and again over time, which means the Container object is recreated at every rebuild. However, as long as canUpdate returns true, it costs less than we tend to suspect because other components, such as Element, State, RenderObject, etc. are reused.

Case 3: canUpdate returns false

So what if canUpdate returns false?

This would be the most heavy case because the old Element is deactivated and new Element is created, meaning State or RenderObject are also created and initialized.

For example, the widget coded below would return false.

Container(
  key: UniqueKey(),
  width: 100,
  height: 100,
  color: Colors.red,
);

The code above always pass UniqueKey() as the key parameter, the comparison of oldWidget.key == newWidget.key always returns false because UniqueKey() is only equal to itself.

test('test UniqueKey comparison', () {
  expect(UniqueKey() == UniqueKey(), isFalse);

  final uniqueKey = UniqueKey();
  expect(uniqueKey == uniqueKey, isTrue);
});

On the other hand, the code below would return true.

Container(
  key: ValueKey('key'),
  width: 100,
  height: 100,
  color: Colors.red,
);

This is because == operator of ValueKey is overridden to compare the value of the keys, which is 'key' in this case.

test('test ValueKey comparison', () {
  expect('key' == 'key', isTrue);
  expect(ValueKey('key') == ValueKey('key'), isTrue);
});

As we have seen above, how == is overridden is crucial to understand the behavior around this. So let's go ahead and take a look at the variation of Key and how each == behaves next.

Keys and == implementations

We have 5 major implementations of Key in Flutter, which are:

  1. UniqueKey

  2. ValueKey

  3. ObjectKey

  4. GlobalKey

  5. GlobalObjectKey

Here, UniqueKey, ValueKey, and ObjectKey are the subclasses of LocalKey, while GlobalKey and GlobalObjectKey are categorized as GlobalKey.

We will discuss the difference between LocalKey and GlobalKey later, but for now, let's focus on how == is implemented in each Key.

UniqueKey

UniqueKey is the simplest implementation of Key. There are no special overrides in it like below.

class UniqueKey extends LocalKey {
  UniqueKey();

  @override
  String toString() => '[#${shortHash(this)}]';
}

This means UniqueKey is just a simple class without any specific logic or internal states. So, UniqueKey() is always equal to itself.

test('test UniqueKey comparison', () {
  expect(UniqueKey() == UniqueKey(), isFalse);

  final uniqueKey = UniqueKey();
  expect(uniqueKey == uniqueKey, isTrue);
});

ValueKey

ValueKey preserves a given value and compares it with another ValueKey's value.

class ValueKey<T> extends LocalKey {
  const ValueKey(this.value);

  final T value;

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is ValueKey<T> && other.value == value;
  }
}

Because == of ValueKey evaluates == of the value inside, ValueKey is equal to another ValueKey as long as the value is the same.

test('test ValueKey comparison', () {
  expect(ValueKey('key') == ValueKey('key'), isTrue);
  expect(ValueKey('key') == ValueKey('key2'), isFalse);
});

ObjectKey

ObjectKey is similar to ValueKey but it compares the value with == operator.

class ObjectKey extends LocalKey {
  const ObjectKey(this.value);

  final Object? value;

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is ObjectKey && identical(other.value, value);
  }
}

While ValueKey compares the value with == operator, ObjectKey compares the value with identical function. In other words, ObjectKey compares the object reference of the value.

test('local key test', () {
  expect(
    ObjectKey(Point(1, 1)) == ObjectKey(Point(1, 1)),
    isFalse, // false because the object reference is different
  );
  expect(
    ObjectKey(const Point(1, 1)) == const ObjectKey(Point(1, 1)),
    isTrue, // true because const refers the same object reference
  );
  expect(ObjectKey('key') == ObjectKey('key'), isTrue);
});

GlobalKey

== implemented in GlobalKey is the same as UniqueKey's ==. It doesn't override ==, meaning GlobalKey object is always equal to itself.

test('test GlobalKey comparison', () {
  expect(GlobalKey() == GlobalKey(), isFalse);

  final globalKey = GlobalKey();
  expect(globalKey == globalKey, isTrue);
});

GlobalObjectKey

GlobalObjectKey is GlobalKey version of ObjectKey. It compares the value with identical function. In other words, GlobalObjectKey compares the object reference to the value.

class GlobalObjectKey<T extends State<StatefulWidget>> extends GlobalKey<T> {
  const GlobalObjectKey(this.value) : super.constructor();

  final Object value;

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is GlobalObjectKey<T> && identical(other.value, value);
  }
test('test GlobalObjectKey comparison', () {
  expect(
    GlobalObjectKey(Point(1, 1)) == GlobalObjectKey(Point(1, 1)),
    isFalse, // false because the object reference is different
  );
  expect(
    GlobalObjectKey(const Point(1, 1)) == const GlobalObjectKey(Point(1, 1)),
    isTrue, // true because const refers the same object reference
  );
});

GlobalKey

Finally, we are at the starting point of the main discussion of this article, GlobalKey.

While Key is used to compare oldWidget and newWidget in canUpdate method, as we discussed before, GlobalKey is used in a lot more places. You can find multiple if (key is GlobalKey) statements in framework.dart.

The two most important operations in this article would be below.

void mount(Element? parent, Object? newSlot) {
  if (key is GlobalKey) {
    owner!._registerGlobalKey(key, this);
  }
}
Element inflateWidget(Widget newWidget, Object? newSlot) {
  if (key is GlobalKey) {
    final Element? newChild = _retakeInactiveElement(key, newWidget);
      if (newChild != null) {

The first one registers Element to BuildOwner with the key of GlobalKey, and then the second one tries to retake the inactive Element using GlobalKey when a new Element should be created.

As we can see here, Element which is created by a widget with GlobalKey is registered to BuildOwner so that it can be referenced or reused later.

The most typical usage of this mechanism would be to refer the State of a widget.

final state = _globalKey.currentState;

Or retrieving the RenderObject of a widget to determine the size of the widget.

final renderObject = _globalKey.currentContext?.findRenderObject();

One thing to note is that .currentState or .currentContext doesn't literally return the State or BuildContext preserved by the GlobalKey.

GlobalKey is an immutable object in the end, and it doesn't preserve any object other than the one we passed to the constructor. Then, what happens when we call .currentState or .currentContext?

We will find it's so simple when we take a look at the implementation of .currentState or .currentContext.

Element? get _currentElement => WidgetsBinding.instance.buildOwner!._globalKeyRegistry[this];

BuildContext? get currentContext => _currentElement;

T? get currentState => switch (_currentElement) {
  StatefulElement(:final T state) => state,
  _ => null,
};

Both the getter of currentContext and currentState call the getter of _currentElement internally to retrieve the relevant Element from the BuildOwner. This can be functioned thanks to WidgetsBinding.instance is a singleton object, which preserves the reference of BuildOwner, meaning they can be accessed from anywhere.

In other words, .currentState or .currentContext just defines the relevant way to retrieve the registered Element as a key-value pair with GlobalKey.

In short, GlobalKey is a "key" to retrieve the registered Element from the BuildOwner. However, "retrieving the registered Element" is also used in the Flutter framework to optimize the construction of the widget tree. Let's deep dive into it next.

Deactivating and Retaking Elements

Once the widget moves to another position or depth on the widget tree, or just removed from the tree, the Element usually will be deactivated by deactivateChild() method as we saw before, and those Element will be recognized as one of the _inactiveElements by BuildOwner.

void deactivateChild(Element child) {
  child._parent = null;
  child.detachRenderObject();
  owner!._inactiveElements.add(child); 
}

Although the deactivated Element will usually be unmounted and disposed accordingly after the current frame, they have another chance to be "retaken" before the next frame comes; that is _retakeInactiveElement() method.

Element? _retakeInactiveElement(GlobalKey key, Widget newWidget) {
  final Element? element = key._currentElement;
  final Element? parent = element._parent;
  if (parent != null) {
    parent.forgetChild(element);
    parent.deactivateChild(element);
  }
  owner!._inactiveElements.remove(element);
  return element;
}

When a new Element is required in rebuilding phase, the Flutter framework first tries to find the deactivated Element to be reused if GlobalKey is given.

Once the corresponding Element of GlobalKey is found, the Element forgets its previous parent and reused as a child of the new parent. It is no longer an "inactive" Element anymore, so it won't be unmounted or disposed after the current frame.

This happens when the depth or position of the widget is changed on rebuilding like below.

final child = SomeWidget(key: GlobalObjectKey('key'));

return _isHorizontal ? Column(children: [child]) : Row(children: [child]);

In this case, if _isHorizontal is true, the child will be at the subtree of Column, while the child will be at the subtree of Row if _isHorizontal is false.

If the key is not given to SomeWidget, the Flutter framework doesn't recognize the child under the Column and the one under the Row as the same object, so its corresponding Element will be unmounted and disposed at any rebuilding.

However, if the GlobalKey is given to SomeWidget, the Flutter framework first registers the corresponding Element to BuildOwner with the key of GlobalKey, and then, tries to retake it even when the position of SomeWidget is changed, which means the State or RenderObject can be kept alive even when _isHorizontal is changed.

This mechanism is also described in the Tree surgery section of Inside Flutter. This let the framework efficiently update the UI.

Conclusion

So far, we have discussed how Key is used for comparing oldWidget and newWidget in the Flutter framework, and how GlobalKey lets the framework efficiently reuse the Element even when its position or depth is changed.

GlobalKey doesn’t only differentiate widgets but also helps keep Element and its corresponding State or RenderObject alive in a BuildOwner singleton object until a certain frame finishes.

My package, animated_to, also leverages this mechanism to determine the update of widgets' positions. I'll hopefully write this in detail in another article.

Though Key is one of the most core concepts in the Flutter framework, it tends to be described with abstract words. I hope this article helps you to understand what exactly happens in the Flutter framework, and how we can leverage GlobalKey in our app development.