The Three Trees, In Depth
Welcome to Part 1 of the Flutter Internals series. This is the series where we stop using Flutter and start understanding how it works underneath — the knowledge that turns "it just works" into "I know exactly why it works, and why this bug happens."
We begin where the framework's heart beats: the three trees. If you read The Three Trees in the Fundamentals series, you met the cast — Widget, Element, RenderObject. This part goes deeper: how Elements actually persist and recycle, the precise rule (canUpdate) that decides whether a widget is updated or replaced, and the BuildOwner machinery that drives every rebuild. Get this and Keys (Part 2), BuildContext (Part 3), and the lifecycle (Part 4) become obvious instead of mysterious.
Foundation for the series. Everything later references this. We're on Flutter 3.38 / Dart 3.12. A quick skim of Fundamentals Part 2 helps but isn't required.
Why three trees at all? The blueprint analogy
Analogy — blueprint, building, materials.
- A Widget is a blueprint: a cheap, immutable description of what you want. You can print a thousand copies; throwing them away costs nothing.
- An Element is the actual building built from a blueprint, standing on a specific plot: it's persistent, it has state, and it knows which blueprint it was built from and which materials it manages.
- A RenderObject is the physical materials and structure — the thing that has real size, position, and gets painted.
Why separate them? Because you rebuild blueprints constantly (every setState, every frame of an animation), but you don't want to demolish and re-pour the building each time. Flutter throws away widgets freely, keeps elements alive, and updates the expensive render objects in place. That separation is the entire performance story.
The one-sentence model: Widgets are immutable configuration you recreate cheaply; Elements are the long-lived instances that hold state and decide what changed; RenderObjects do layout and painting. Rebuilding is cheap because only the first tree is actually rebuilt.
The Widget tree: immutable configuration
A Widget is just an immutable bundle of configuration. It has no mutable state of its own (a StatefulWidget's state lives in its State, held by the Element). Every build() returns a brand-new widget tree:
// Every rebuild creates new Widget objects — this is cheap by design (const helps).
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Text('Count: $count'), // a NEW Text widget every rebuild
);
}
Because widgets are disposable, the framework needs something else to remember "what's actually on screen" across rebuilds. That something is the Element.
The Element tree: the persistent middle layer
The Element is the unsung hero. For every widget that's mounted, there's exactly one Element. The Element:
- Persists across rebuilds — the same Element survives many widgets.
- Holds the
Stateof aStatefulWidget(this is why state outlives rebuilds). - Is the
BuildContext— yes,contextis the Element (Part 3 goes deep). - Knows its current widget and its child element(s), and manages the associated RenderObject.
There are two essential flavors:
| Element kind | Wraps | Examples |
| --- | --- | --- |
| ComponentElement | A widget that composes others (no render object) | StatelessElement, StatefulElement |
| RenderObjectElement | A widget that creates a RenderObject | the element behind RenderObjectWidgets |
Key realization: when you call
setState, you don't create or destroy Elements. You mark the Element dirty; on the next frame it asks its widget tobuild()a new widget subtree and then reconciles that against its existing children. The Element tree is the stable skeleton; widgets flow through it.
Reconciliation: the canUpdate rule
Here's the mechanism that makes the whole thing work — and the source of more "why did my state reset?!" bugs than anything else in Flutter.
When an Element rebuilds and gets a new child widget, it must decide: can I keep my existing child Element (and its state + render object) and just feed it the new widget? Or do I have to throw it away and build a fresh one?
The decision is made by a single static method, Widget.canUpdate(old, new):
// Simplified from the framework:
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
The rule that explains everything: an Element is reused for a new widget only if the new widget has the same
runtimeTypeand the samekey. Same type + same key → update in place (state preserved). Different type or different key → old Element is deactivated/disposed and a new one is built (state lost).
Let's see all four outcomes:
// Rebuild 1 → Rebuild 2, what happens to the Element & state?
Text('A') → Text('B') // same type, same (null) key → REUSED, just new text
Text('A') → Container() // different type → REPLACED, old element disposed
Foo(key: Key('x')) → Foo(key: Key('x')) // same type+key → REUSED
Foo(key: Key('x')) → Foo(key: Key('y')) // same type, different key → REPLACED
This is why:
- A
StatefulWidgetkeeps its state across rebuilds — same type+key, so the Element (holding theState) is reused. - Changing a widget's type, or its key, resets its state — the Element is rebuilt from scratch.
Keys (Part 2) exist entirely to control this decision. Now you know what they actually control: canUpdate.
updateChild: the four-way decision
Each Element reconciles its children through updateChild(oldChildElement, newChildWidget). Conceptually:
updateChild(child, newWidget):
if newWidget == null:
→ child is gone: deactivate the old child element (if any)
else if child == null:
→ new child: inflate newWidget into a fresh Element (mount it)
else if Widget.canUpdate(child.widget, newWidget):
→ REUSE: child.update(newWidget) // keep element, state, render object
else:
→ REPLACE: deactivate old child, inflate newWidget into a new Element
Why this is fast: in the common case (same structure, changed data),
canUpdatereturnstrueall the way down, so Flutter walks the existing Element tree and just updates widgets and render-object properties — no allocation of new Elements or RenderObjects, no re-layout of unchanged structure. Rebuilding a whole screen often touches only the few render objects whose properties changed.
The RenderObject tree: where pixels happen
The third tree is the smallest and most expensive. RenderObjectElements create and manage RenderObjects, which do the heavy work: layout (compute size/position via constraints) and paint (draw to the canvas). Many widgets (like Padding, Text, Opacity) have a render object; pure composition widgets (your StatelessWidgets) don't — they just produce more widgets.
We dedicate all of Part 6 to RenderObjects. For now, the crucial fact:
RenderObjects are kept and mutated in place whenever
canUpdatelets the Element be reused. ChangingText's string updates the existingRenderParagraph's text and marks it needing paint — it does not create a new render object. This in-place update is why rebuilds don't trigger full re-layouts of the screen.
BuildOwner: who actually drives a frame
What turns a setState into pixels? A coordinator called the BuildOwner holds the list of dirty elements (the ones marked via markNeedsBuild). Each frame:
setState→ theState's Element callsmarkNeedsBuild()→ it's added to theBuildOwner's dirty list.- On the next frame,
BuildOwner.buildScoperebuilds dirty elements in tree order (parents before children), each callingbuild()andupdateChildto reconcile. - Reconciled render objects are marked needing layout/paint; the rendering pipeline then lays out and paints them.
// The chain you now understand fully:
setState(() => count++); // mutate State fields
// → Element.markNeedsBuild() → BuildOwner dirty list
// → next frame: build() → updateChild() → canUpdate() decides reuse/replace
// → RenderObject.markNeedsPaint/Layout → pixels
This is the same
markNeedsBuildfrom State Management Part 1 — now you see the full path from a field change to a repaint. The Element/BuildOwner layer is exactly the thingsetStatepokes.
Putting it together: a worked rebuild
class Demo extends StatefulWidget {
const Demo({super.key});
@override
State<Demo> createState() => _DemoState();
}
class _DemoState extends State<Demo> {
bool big = false;
@override
Widget build(BuildContext context) {
return Column(children: [
const Text('Header'), // (A)
Text(big ? 'BIG' : 'small'), // (B)
ElevatedButton(
onPressed: () => setState(() => big = !big),
child: const Text('Toggle'),
),
]);
}
}
Tap Toggle:
setStatemarks_DemoState's Element dirty (BuildOwner).- Next frame:
build()returns a newColumnwith new children. - Reconcile:
Columnelement is reused (same type+key). For each child,canUpdate:- (A)
const Text('Header')→ same type+key → reused, and since it'sconstand identical, Flutter can skip it entirely. - (B)
Text(...)→ same type+key → reused; its render object's text is updated to'BIG'and marked needing paint. - Button → reused; its
const Text('Toggle')child untouched.
- (A)
- Only (B)'s render object repaints. No Elements created or destroyed.
That's the elegance: a whole-tree build(), but near-zero actual work because the Element tree absorbs the change and reuses everything possible.
Practice Challenges
Challenge 1 — Reuse or replace? For each rebuild transition, say whether the child Element (and its state) is reused or replaced: (a) Text('a') → Text('b'), (b) Text('a') → Container(), (c) Foo(key: ValueKey(1)) → Foo(key: ValueKey(2)).
Show solution
(a) Reused — same type, same (null) key; just updates text. (b) Replaced — different runtimeType. (c) Replaced — same type but different key, so canUpdate returns false. State is preserved only in (a).
Challenge 2 — What persists? A StatefulWidget's State survives a rebuild of its parent. Which tree makes that possible, and why?
Show solution
The Element tree. The State is held by the StatefulElement, which persists across rebuilds as long as canUpdate keeps it (same type+key). Widgets are recreated each build, but the Element — and thus the State — lives on.
Challenge 3 — Name the method. Which static method decides whether an Element can be reused for a new widget, and what two things does it compare?
Show solution
Widget.canUpdate(old, new) — it compares runtimeType and key. Both must match for the Element to be reused (updated in place).
Challenge 4 — Trace the frame. List, in order, what happens from setState(() => x++) to a repaint, naming markNeedsBuild, BuildOwner, canUpdate, and the render object.
Show solution
setState mutates fields and calls markNeedsBuild() on the Element → it's added to the BuildOwner dirty list → next frame BuildOwner rebuilds it (build()), and updateChild/canUpdate reconcile children, reusing Elements where possible → changed RenderObjects are marked needing layout/paint → the pipeline paints. (State Mgmt Part 1)
Challenge 5 — Why so cheap? Explain why returning an entirely new widget tree from build() every frame isn't wasteful.
Show solution
Widgets are immutable, cheap config objects (especially const ones, which are canonicalized and skipped). The expensive Elements and RenderObjects are kept and updated in place via reconciliation — only properties that actually changed are touched. So a full build() translates into minimal real work.
Questions to test yourself
Q1 (basic). What is each of the three trees responsible for?
Show answer
Widget = immutable configuration/blueprint (recreated each build). Element = persistent instance that holds state, is the BuildContext, and reconciles children. RenderObject = layout and painting (size, position, pixels).
Q2 (basic). Where does a StatefulWidget's mutable state actually live?
Show answer
In the State object held by the StatefulElement (in the Element tree), not in the widget. That's why state survives widget rebuilds.
Q3 (intermediate). State the canUpdate rule and its consequence for state.
Show answer
An Element is reused for a new widget only if runtimeType and key both match. Match → update in place (state preserved); mismatch → old Element disposed and a new one built (state lost).
Q4 (intermediate). Walk through updateChild's four cases.
Show answer
New widget null → deactivate old child. Old child null → inflate/mount the new widget into a fresh Element. canUpdate true → reuse: child.update(newWidget). canUpdate false → replace: deactivate old, inflate new. This is how each Element reconciles its children every rebuild.
Q5 (advanced). What is the BuildOwner and what does it do each frame?
Show answer
The BuildOwner tracks dirty elements (those that called markNeedsBuild, e.g. via setState). Each frame it rebuilds them in tree order (buildScope), running build() and updateChild to reconcile, after which changed render objects are laid out and painted. It's the coordinator between "something changed" and "rebuild happened."
Q6 (advanced). Why doesn't changing a Text's string create a new RenderObject?
Show answer
Because canUpdate matches (same Text type + key), so the Element is reused and just calls update with the new widget, which mutates the existing render object's text and marks it needing paint. Creating a new render object only happens when the Element is replaced (type/key change). In-place update is what keeps rebuilds cheap.
Wrapping up
- Three trees, three jobs: Widgets = immutable config (recreated cheaply), Elements = persistent instances (hold state, are the
BuildContext, reconcile), RenderObjects = layout + paint. canUpdate(sameruntimeType+key) decides whether an Element is reused (state kept) or replaced (state lost) — the root of most "state reset" bugs.updateChildreconciles each Element's children with that rule; reuse means in-place updates, not new allocations.BuildOwnercollects dirty elements (frommarkNeedsBuild) and rebuilds them each frame — the full path fromsetStateto pixels.- Rebuilding the whole widget tree is cheap because the Element tree absorbs the change and reuses everything it can.
In Part 2 we take direct control of the canUpdate decision: Keys — ValueKey, ObjectKey, GlobalKey — and exactly when you need them to fix (or avoid) state-preservation bugs.