DEV Community

Ishaq Hassan
Ishaq Hassan

Posted on • Originally published at ishaqhassan.dev

Flutter Three-Tree Architecture Explained: Widgets, Elements, RenderObjects

TL;DR

Flutter renders by maintaining three parallel trees. The Widget tree is immutable configuration, rebuilt on every setState. The Element tree is the persistent identity layer that decides whether to update or replace. The RenderObject tree is the heavyweight layout, paint, and hit-test machine. Bugs that look like "random state loss" or "ListView jank" almost always live in the Element layer.

Why three trees instead of one

If Flutter rebuilt the entire rendered UI on every state change, no app would hold 60fps. Native frameworks solve this with mutable view objects you imperatively update. React solves it with a virtual DOM diff against a real DOM. Flutter splits the problem into three layers, each with one job.

Widgets are immutable build instructions. They are cheap to allocate, cheap to throw away, and trivially comparable. When you call setState, Flutter does not rebuild any pixels. It rebuilds the widget subtree below the StatefulWidget into fresh widget instances. Widgets are fast because they are dumb.

Elements are the persistent identity layer. Every widget gets exactly one Element when first mounted. The Element holds the parent pointer, the state object (for StatefulWidgets), the InheritedWidget dependencies, and a reference to the RenderObject if it has one. Elements survive across setState calls.

RenderObjects are the real work. Layout, paint, hit-testing, semantics, accessibility tree generation. RenderObjects are expensive to create and expensive to throw away, so the Element layer works hard to keep them around across rebuilds.

The reconciliation algorithm

When the framework asks an Element to update with a new widget, the algorithm is:

if (Widget.canUpdate(oldWidget, newWidget)) {
  element.update(newWidget);     // reuse this Element + RenderObject
} else {
  element.deactivate();           // unmount old subtree
  parent.inflateWidget(newWidget); // create fresh Element + RenderObject
}
Enter fullscreen mode Exit fullscreen mode

The canUpdate check returns true when the runtimeType and the key are equal. Same type, same key (or both null) means the Element is reused. Anything else means a full mount/unmount cycle.

Keys and why they matter more than you think

Keys are the manual override on the reconciliation algorithm. By default, Flutter pairs new widgets to existing Elements positionally. The first child of the new widget tree pairs with the first child Element. If you reorder a list of Card widgets without keys, every Card looks at its new widget, sees a Card with the same runtimeType and a null key, and updates in place. The visible content moves, but the state stays at its original position.

This is the source of the most reported "Flutter is broken" bug: a list of TextField widgets where the user types in the second row, then the row gets reordered, and the typed text appears in the wrong row. The fix is to give each Card a stable key (like ValueKey(item.id)) so the framework pairs by identity instead of position.

What to do with this knowledge

You do not need to memorize the framework source. But you do need a working mental model of which tree is responsible for which behavior:

  • If your UI looks wrong after a rebuild, suspect the Widget tree.
  • If your state vanishes or moves, suspect the Element tree.
  • If your frame rate drops, suspect the RenderObject tree.
  • If InheritedWidget consumers do not update, suspect the Element dependency graph.

The full version with the six-state Element lifecycle, RenderObject layer details, and a real bug case study from a merged Flutter framework PR is on my site:

Read the complete article on ishaqhassan.dev

I am a Flutter Framework Contributor with 6 merged PRs into flutter/flutter. More writing at ishaqhassan.dev/blog/.

Top comments (0)