The Three Trees: How Flutter Actually Renders Your UI
Widget, Element and RenderObject are three separate trees doing three different jobs. Once you can see all three, const, Key, BuildContext and the real cost of a rebuild stop being folklore.
Most Flutter developers learn widgets first and never learn what sits behind them. That works right up until the day something behaves in a way widgets alone cannot explain: a text field that loses its contents when a list reorders, an animation that restarts for no reason, a rebuild you were told would be cheap that clearly is not. At that point the mental model runs out, and the usual response is to sprinkle keys and const around until the symptom goes away.
The missing piece is that Flutter does not maintain one tree. It maintains three, and they have very different jobs, lifetimes and costs. Almost every confusing behaviour in the framework becomes obvious once you can see all three.
“A widget is not what you see on screen. It is a description of what you want to see. The screen is the render tree; the widget is only the instruction that produced it.”
Three trees, three jobs
| Tree | What it is | Lifetime | Cost |
|---|---|---|---|
| Widget | Immutable configuration — a blueprint | Thrown away every build | Very cheap |
| Element | The instance holding position, State and lifecycle | Persists across rebuilds | Moderate |
| RenderObject | The thing that lays out, paints and hit-tests | Persists, mutated in place | Expensive |
Read that middle row twice. The element tree is the one that persists, and it is the one nobody talks about. It is the bridge between the blueprint you rewrite sixty times a second and the render objects you very much do not want to rebuild sixty times a second.
The widget tree: immutable configuration
A widget is an immutable value object. Every field is final. Building one allocates a small object and nothing else — it does not touch the screen, measure anything, or paint a single pixel. This is why "widgets are cheap" is true, and also why it is so often misunderstood: creating the widget is cheap, but what Flutter then does with it may not be.
// This is not a button on screen. It is a description of one.
class SaveButton extends StatelessWidget {
const SaveButton({super.key, required this.onSave});
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onSave,
child: const Text('Save'),
);
}
}The element tree: the part that persists
When Flutter mounts a widget it calls createElement(), and the resulting Element is inserted into the element tree. That element remembers its position in the tree, its parent and its children — and for a StatefulWidget, it owns the State object. The widget gets replaced on every build. The element and its State stay put.
This answers a question most people never think to ask: if widgets are immutable and thrown away constantly, where does my state actually live? It lives on the element. That is precisely why it survives.
The render tree: layout, paint, hit testing
Only a RenderObjectWidget — Padding, Opacity, RichText and friends — produces a RenderObject. Your StatelessWidget does not; it exists purely to produce more widgets. RenderObjects are the expensive layer: they cache layout information, participate in the constraints-down / sizes-up algorithm, paint into layers and answer hit tests. Flutter goes to real lengths to mutate them in place rather than recreate them.
What actually happens when you call setState
setState marks the element dirty. On the next frame Flutter rebuilds that element's widget subtree, then walks the new widgets against the existing elements one position at a time. At each position it asks a single question:
// Widget.canUpdate — the entire reconciliation rule, in two lines.
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}Same runtime type and same key? Flutter keeps the existing element, hands it the new widget, and lets it update its render object in place. Different type or different key? It tears down the old element along with its State, and builds a fresh one.
- Rebuilding a widget does not rebuild the element — it updates it.
- Updating an element does not recreate the render object — it mutates it.
- So the real cost of a rebuild is the reconciliation walk plus any layout it dirties, not the widget allocations.
- A subtree whose widget is the identical instance as last frame is skipped entirely.
Why const is a genuine optimization
That last point is the whole reason const matters. Dart canonicalizes const values, so the same const expression evaluates to the exact same instance every time. When Flutter reaches a position and finds the new widget is identical to the one already there, it short-circuits and skips that entire subtree — no rebuild, no element walk, nothing.
@override
Widget build(BuildContext context) {
return Column(
children: [
// A new instance every build, so it is reconciled every build.
Text('Header'),
// The same instance every build, so the subtree is skipped.
const Text('Header'),
],
);
}Notice what const is not doing. It is not making the allocation cheaper — that was already trivial. It is buying an early exit from reconciliation. Which is why const pays off most on the deep, static parts of a tree and barely registers on a single leaf.
What a Key actually does
Keys exist because canUpdate matches by position and type. In a list of identical widget types, position is the only thing distinguishing one from another — so when the list reorders, Flutter cheerfully matches the new first item against the old first element. The configuration updates correctly. The State stays where it was.
// Reorder this list and the checkbox states follow position,
// not the todo they belong to.
ListView(
children: todos.map((todo) => TodoTile(todo: todo)).toList(),
)
// With a key, the element — and its State — travels with the todo.
ListView(
children: todos
.map((todo) => TodoTile(key: ValueKey(todo.id), todo: todo))
.toList(),
)This is the classic "I reordered my list and the wrong row is checked" bug, and it is not a bug in Flutter. It is the framework doing exactly what it was told: match by position, because you gave it nothing better to match on.
When you actually need one
- A list of stateful widgets that can reorder, insert or delete in the middle — use ValueKey with a stable business id.
- Swapping between two widgets of the same type where state must not carry over.
- Preserving state while moving a widget to a different place in the tree — the narrow case GlobalKey exists for.
- Everywhere else you almost certainly do not need one. Keys on stateless widgets buy nothing.
Why State survives a rebuild but not a type change
Because State hangs off the element, and the element survives exactly as long as canUpdate keeps returning true. Change the widget's runtime type at a given position and the element is discarded, dispose() runs, and everything it held goes with it — scroll offsets, animation controllers, text editing controllers, half-filled forms.
// Looks harmless. Every toggle destroys the element at this
// position, so anything stateful below it is reset.
isLoading
? const CircularProgressIndicator()
: ProfileForm(user: user)If that reset is not what you wanted, the fix is structural rather than cosmetic: keep the widget type stable at that position and vary something inside it, or lift the state above the point where the type changes.
BuildContext is an Element
BuildContext is not a bag of data handed to build(). It is an interface implemented by Element — a handle to your own position in the element tree. Once you know that, the two most common context errors explain themselves.
Scaffold.of(context) walks up the tree from the element you gave it. Inside the build method that created the Scaffold, your context sits above that Scaffold, not below it — so the walk never finds it. A Builder fixes this by introducing a new element one level further down.
@override
Widget build(BuildContext context) {
return Scaffold(
// This context sits ABOVE the Scaffold. The lookup fails.
body: ElevatedButton(
onPressed: () => Scaffold.of(context).openDrawer(),
child: const Text('Open'),
),
);
}
// Builder creates a new element below the Scaffold,
// so its context can see it.
body: Builder(
builder: (innerContext) => ElevatedButton(
onPressed: () => Scaffold.of(innerContext).openDrawer(),
child: const Text('Open'),
),
)The same reasoning covers using a context after an await. By the time the future completes, that element may have been unmounted — the position it referred to no longer exists. Checking mounted before touching it is not superstition; it is asking whether the element is still in the tree.
What this buys you in practice
| Symptom | Real cause | What to do |
|---|---|---|
| Wrong row selected after reordering a list | Elements matched by position | ValueKey with a stable id |
| TextField loses its contents on rebuild | Element discarded, State disposed | Keep the widget type stable at that position |
| Animation restarts unexpectedly | Controller lived on a destroyed State | Lift it above the type change, or key the widget |
| Rebuilds feel expensive | Reconciliation walking a deep subtree | const the static parts to short-circuit the walk |
| A .of(context) lookup finds nothing | The context sits above the widget being looked up | Introduce a Builder below it |
Conclusion
Widget, Element, RenderObject. Configuration, instance, pixels. The widget tree is a script you rewrite constantly; the element tree is the cast that stays on stage between takes; the render tree is the set, rebuilt only when it genuinely has to change.
None of the advice here is new — const your static widgets, key your reorderable lists, mind your context. What changes once you can see the three trees is that you stop applying it as ritual. You can look at a widget tree and reason about which elements will survive the next frame, and that is the difference between guessing at a fix and knowing why it works.