How Flutter actually draws all 5 written
The three trees, constraints, keys and the rendering pipeline. The most common senior interview topic.
14 Widget, Element and RenderObject
Flutter keeps three trees, not one. Almost every confusing Flutter behaviour — why state survived a rebuild, why it did not, why your animation restarted — is explained by the difference between them.
What each tree is
- Widget — a description, immutable and cheap. Thrown away and rebuilt constantly. A widget is not the thing on screen; it is a recipe for it.
- Element — the long-lived instance that sits in a position in the
tree. This is where
Statelives, which is why state can survive a rebuild. - RenderObject — the thing that measures, positions and paints. This is the expensive one.
When you call setState, you create new Widgets. Flutter
then walks the Element tree and, for each position, asks a single question: is the new
widget the same runtime type and the same key as the old one? If yes, the Element is
reused and only updated. If no, the Element is destroyed and a new one
built — and any State it held is gone.
// The decision Flutter makes at every position in the tree, in one function.
// This is Widget.canUpdate, essentially verbatim.
bool canUpdate(({Type type, Object? key}) oldW, ({Type type, Object? key}) newW) {
return oldW.type == newW.type && oldW.key == newW.key;
}
void main() {
final text = (type: String, key: null);
final otherText = (type: String, key: null);
final number = (type: int, key: null);
final keyed = (type: String, key: 'a');
final differentKey = (type: String, key: 'b');
print('same type, no keys -> reuse: ${canUpdate(text, otherText)}');
print('different type -> reuse: ${canUpdate(text, number)}');
print('same type, same key -> reuse: ${canUpdate(keyed, keyed)}');
print('same type, different key -> reuse: ${canUpdate(keyed, differentKey)}');
test('same type and no key means the Element is reused', canUpdate(text, otherText), true);
test('a different type means it is thrown away', canUpdate(text, number), false);
test('a different key also throws it away', canUpdate(keyed, differentKey), false);
test('reuse is what preserves State', canUpdate(keyed, keyed), true);
}
What it printed, when the build ran it
same type, no keys -> reuse: true different type -> reuse: false same type, same key -> reuse: true same type, different key -> reuse: false ok same type and no key means the Element is reused ok a different type means it is thrown away ok a different key also throws it away ok reuse is what preserves State
Proving it, with a real widget test
The claim above is that a State object survives a rebuild as long as the
type and key match. That is testable, so here it is tested. This panel ran as a real
widget test during the build.
That test is close to the ideal interview answer, because it does not just assert the rule — it demonstrates the consequence. The label changed while the count survived, and then the key changed and the count reset. If you can explain why those two rebuilds behave differently, you have understood the Element tree.
The practical version of this. "My text field cleared itself when the list reordered" and "my scroll position jumped" are both this rule. Something above changed a widget's type or key, the Element was rebuilt, and the State went with it. Keys are the subject of lesson 16.
15 Constraints go down, sizes go up
Constraints go down, sizes go up, and the parent sets the position. Three clauses, and every layout error you have ever seen is one of them going wrong. The useful version of this knowledge is being able to say which number was missing.
The protocol, precisely
- A parent hands each child a BoxConstraints: four numbers, a minimum and maximum for width and for height.
- The child picks a size inside those numbers. It cannot pick one outside them — the parent's constraint wins, silently.
- The parent then decides where the child goes. A child never knows its own position, which is why you cannot ask for it during layout.
So a widget has exactly two levers: what constraints it passes down, and what size it
reports up. SizedBox passes down a tight constraint, where
the minimum equals the maximum, so the child has no choice at all.
Center, Align and Padding pass down a
loose one, where the minimum is zero, so the child may be small.
// BoxConstraints, which is only ever these four numbers and one clamp.
class Constraints {
const Constraints(this.minWidth, this.maxWidth, this.minHeight, this.maxHeight);
final double minWidth;
final double maxWidth;
final double minHeight;
final double maxHeight;
/// Tight means the parent has already decided: there is one legal size.
bool get isTight => minWidth == maxWidth && minHeight == maxHeight;
bool get hasBoundedHeight => maxHeight < double.infinity;
/// What the child is allowed to become, given what it asked for. The child does
/// not get the last word here; this clamp does.
double constrainWidth(double want) => want.clamp(minWidth, maxWidth);
/// What Center, Align and Padding do: keep the ceiling, drop the floor.
Constraints loosen() => Constraints(0, maxWidth, 0, maxHeight);
}
void main() {
const tight = Constraints(300, 300, 200, 200);
const loose = Constraints(0, 300, 0, 200);
const unbounded = Constraints(0, 300, 0, double.infinity);
print('tight, child wants 50 -> ${tight.constrainWidth(50)}');
print('loose, child wants 50 -> ${loose.constrainWidth(50)}');
print('loose, child wants 900 -> ${loose.constrainWidth(900)}');
test('a tight constraint ignores what the child wanted', tight.constrainWidth(50), 300);
test('a loose one lets the child stay small', loose.constrainWidth(50), 50);
test('but never bigger than the maximum', loose.constrainWidth(900), 300);
test('loosen keeps the ceiling and drops the floor', tight.loosen().minWidth, 0);
test('tight means there is one legal size', tight.isTight, true);
test('an unbounded axis has no ceiling at all', unbounded.hasBoundedHeight, false);
}
What it printed, when the build ran it
tight, child wants 50 -> 300.0 loose, child wants 50 -> 50.0 loose, child wants 900 -> 300.0 ok a tight constraint ignores what the child wanted ok a loose one lets the child stay small ok but never bigger than the maximum ok loosen keeps the ceiling and drops the floor ok tight means there is one legal size ok an unbounded axis has no ceiling at all
Reading the real numbers
LayoutBuilder is the only place you can see the constraints your widget
was actually given, and it is the debugging tool worth reaching for before any of the
others. This test puts one inside a SizedBox and one inside an
Align, and reads the four numbers out of each.
Note what Align did. It kept the same maximum and dropped the minimum to
zero. That is the whole of "loosening", and it is why wrapping something in a
Center changes its size: you did not resize it, you gave it permission to
choose.
Expanded and Flexible, which are not synonyms
Inside a Row or Column, layout happens in two passes. First
the non-flexible children are laid out and their sizes subtracted. Whatever is left is
then divided between the flexible ones in proportion to their flex. The
difference between the two wrappers is what they do with their share:
ExpandedisFlexFit.tight. The child must be exactly its share.FlexibleisFlexFit.loose. The child may be up to its share, and is free to be smaller.
Read the last two numbers again. Flexible was offered 150 and took 20.
Expanded was offered 150 and took 150 — not 280, because its share was
already decided before either child was laid out. Flex is divided, not competed for.
The unbounded constraint, which is the error you will actually hit
A Column gives its non-flexible children an unbounded
maximum height, because a column's job is to be as tall as its contents. A
ListView does the opposite: it expands to fill whatever height it is given.
Put one inside the other and the two policies deadlock — the list is told "be any height
you like" and has no way to choose.
Thirty errors from one mistake. That is worth knowing before you debug one: the
first message is the only one that describes your bug, and the rest are
RenderBox was not laid out complaints from everything downstream of a box
that never got a size. Always scroll to the top of the red.
The three fixes, in order of preference. Expanded when the list should
take the leftover space. SizedBox(height: n) when you know the number.
shrinkWrap: true only when the list is genuinely short, because it lays out
every child on every frame to measure itself, and that is the setting that quietly makes
long lists janky.
IntrinsicHeight, and why it is the expensive answer
Sometimes you want siblings to agree on a height that none of them was given — two
cards in a row, both as tall as the taller one. Nothing in the protocol above allows
that, because a child reports its size after the constraint arrives, not before.
IntrinsicHeight buys the missing information by running an extra pass:
it asks the children how tall they would like to be, then hands the answer back down as
a tight constraint.
What it costs. Layout is normally one pass down and one pass up, so it is linear in the number of render objects. An intrinsic query walks the subtree again to measure it, and if intrinsics nest, that walk nests too — the framework's own docs warn it can become quadratic. Fine for two cards in a row. Not fine inside a list item that is built sixty times a second. When you can compute the height yourself, do.
The interview version of this lesson is not the slogan. It is being able to take a layout complaint — "it overflowed", "it filled the screen", "it collapsed to nothing" — and say which of the four numbers was wrong and who passed it. Nothing collapsed to nothing without a minimum of zero; nothing filled the screen without a minimum that was already the maximum.
16 Keys, and the bug they exist to fix
A key is not decoration and it is not an id for your benefit. It is the second half of the question Flutter asks at every position in the tree: same type, and same key? Without one, the answer is decided by position alone — and position is exactly what changes when a list reorders.
The bug, before the explanation
Two rows, each a StatefulWidget holding a tap count. Tap the first one,
then swap the two rows over. The count stays where it was, and the label moves — so the
count is now attached to the wrong row. This is a real widget test, and it ran during the
build.
Follow what happened at position zero. The old widget there was
Tile(name: 'ada'); the new one is Tile(name: 'bode'). Same
type, both keys null, so canUpdate says yes: keep the Element, keep its
State, just hand it a new widget. The count was in that State. It never
moved, because nothing told Flutter it should.
With ValueKey('ada') on the row, position zero's old key is
'ada' and its new key is 'bode'. They do not match, so Flutter
goes looking for an Element with key 'ada' elsewhere in the same list, finds
it at position one, and moves it. The State travels with it.
Why this hides so well. A stateless list reorders correctly without keys, because there is nothing to attach to the wrong row. The bug only appears once a row holds something: a checkbox, a text field, an expanded or collapsed flag, a scroll position, an animation part-way through. Which is also why it tends to arrive as a bug report months after the list was written.
The four kinds, and what each compares
ValueKey(v)— comparesvby==. The default choice. Use the domain identifier: an id, not an index.ObjectKey(o)— comparesoby identity. For when two items are equal but not the same, or when the value has no stable id.UniqueKey()— equal to nothing, including a fresh copy of itself. Guarantees a new Element, which means it guarantees losing the State.GlobalKey()— unique across the whole app, and lets you reach the Element, its State and its RenderObject from anywhere.
That second test is the one worth remembering. The row went from being the only child
of a Column to being the second child of a Row — a completely
different place in the tree — and the count survived. A GlobalKey is
registered app-wide, so when the new tree is built Flutter finds the existing Element by
key and reparents it rather than building a new one. This is how a Hero
animation keeps its widget alive across a route change.
Choosing, in one page
- Reorderable or filterable list of stateful rows —
ValueKey(item.id). This is nine times out of ten. - Items with no id —
ObjectKey(item), as long as the objects themselves are stable between builds. If you rebuild the list from JSON each time, they are not, and you need an id. - You want the State thrown away —
UniqueKey(). Real use: restarting an animation, or forcing a form to reset. It is a blunt instrument and that is the point. - You need to reach into a widget from outside —
GlobalKey. Almost always a form:formKey.currentState!.validate(). - Nothing stateful below, no reordering — no key. Adding one costs a little and buys nothing.
Two mistakes worth naming. ValueKey(index) is not a key,
it is a slower way of writing position — the index of a row changes when the list
reorders, which is precisely the case you were trying to fix. And a
GlobalKey created inside build is a new key on every rebuild,
so it destroys the Element it was meant to identify; they belong in a
State field or a top-level final.
If an interviewer asks about keys, describe the bug rather than the API. "A list of
rows with checkboxes reordered, and the ticks stayed on the old positions, because
Flutter matched the Elements by position and the position was the thing that changed."
Then name the fix and the reason ValueKey(index) is not it. That answer is
hard to give without having hit it.
17 What causes a rebuild, and what it costs
A rebuild is one call to build, producing new widget
objects. It is cheap — widgets are small immutable descriptions. What is not cheap is
doing it to a thousand widgets sixty times a second, or letting a rebuild reach code that
is doing real work. The skill is knowing what your rebuild touches.
What actually triggers one
setState— marks that one Element dirty. Its wholebuildruns again, and so does everything that build produces.- A parent rebuilding — a new child widget object arrives, so the child rebuilds too, unless it is the same object as last time.
- An
InheritedWidgetabove you changing, if you asked it for something. Lesson 20. - A
Listenableyou are listening to firing:AnimationController,ValueNotifier, a scroll position.
The second one is where the const advice comes from. When Flutter updates
a position in the tree, the very first thing it checks is whether the new widget is
identical to the old one. If it is, it stops: no rebuild, no diff,
nothing. A const constructor is canonicalised by the compiler, so
const Frozen() in two different frames is the same object — and the check
passes.
One tap, two rebuilds of the parent, and Frozen.build ran exactly once
in total. That is the entire mechanism: const does not make building
faster, it makes building unnecessary. And it only works while the widget takes
no runtime values — the moment you pass it a variable, it cannot be const, and the
saving is gone.
Narrowing the scope, which beats const every time
const protects leaves. It does nothing for the branch above them, because
the branch has to be rebuilt for the const child to be reached at all. The bigger win is
to stop the rebuild starting so high: put the changing value behind a builder that sits
next to the widget that displays it. Here is the same feature twice, with the builds
counted.
Note that Narrow is still a StatefulWidget — it has to be,
because somebody must own and dispose the notifier. What changed is that the
notifier's change no longer runs _NarrowState.build. The listener is the
ValueListenableBuilder's Element, three levels down, so that is the only
Element marked dirty. Every state management library in the next part is a more elaborate
version of this one move.
What it costs, in numbers you can quote
A rebuild is not free, it is just cheap, and the cost is linear in the number of widgets
the rebuild reaches. That makes the depth at which you called setState the
only variable that matters.
// A screen as a perfectly balanced tree, so the arithmetic is honest: every widget
// has three children, and the tree is five levels deep.
int subtreeSize(int branching, int depth) {
var total = 0;
var level = 1;
for (var d = 0; d <= depth; d++) {
total += level;
level *= branching;
}
return total;
}
void main() {
print('setState at the root -> ${subtreeSize(3, 5)} widgets rebuild');
print('setState three levels down -> ${subtreeSize(3, 2)} widgets rebuild');
print('setState on a leaf -> ${subtreeSize(3, 0)} widgets rebuild');
test('a rebuild at the root touches the whole screen', subtreeSize(3, 5), 364);
test('the same change three levels down touches far less', subtreeSize(3, 2), 13);
test('which is a factor of 28, for moving one call', subtreeSize(3, 5) ~/ subtreeSize(3, 2), 28);
test('a leaf rebuild is one widget', subtreeSize(3, 0), 1);
}
What it printed, when the build ran it
setState at the root -> 364 widgets rebuild setState three levels down -> 13 widgets rebuild setState on a leaf -> 1 widgets rebuild ok a rebuild at the root touches the whole screen ok the same change three levels down touches far less ok which is a factor of 28, for moving one call ok a leaf rebuild is one widget
Where a rebuild stops being cheap. Building widgets is cheap;
building them is not the only thing that happens. If build allocates
(a new DateFormat, a decoded image, a sorted copy of a list), that cost is
paid per rebuild. If it changes a size, layout runs again. If it changes a paint, the
raster thread runs again. And a ListView that is rebuilt loses nothing but
still re-runs its builder for every visible item. Sixteen milliseconds is the whole
budget for all of it.
The order to fix things in, which is also the order to say them in an interview:
measure first with the rebuild counter or the timeline, then move the state down, then
put a builder around the part that changes, then make the leaves const. People do it
backwards — they scatter const everywhere and never move the
setState that is rebuilding the screen.
18 State lifecycle, and didUpdateWidget
A State object outlives the widgets that describe it.
Five callbacks mark the transitions, and each one exists because there is something you
can only correctly do at that moment. Doing it a moment earlier or later is most of what
goes wrong.
The five, and what each is for
initState— once per State. Subscribe, create controllers, start the first load.contextexists but nothing inherited is safe to read yet.didChangeDependencies— right afterinitState, and again whenever anInheritedWidgetyou depend on changes. This is whereTheme.oforMediaQuery.ofwork is safe.didUpdateWidget(old)— the parent rebuilt and handed you a new widget of the same type. Compareoldwithwidgetand react only to what changed.build— describe the UI. Nothing else. It runs often and at times you do not choose.dispose— once, at the end. Cancel every subscription, dispose every controller, stop every timer. Anything you started ininitStateends here.
Two things in that log are worth pausing on.
didChangeDependencies fires before the first build, not only on
change — so it is a legitimate place for setup that needs inherited data, and you do not
need to special-case the first run. And didUpdateWidget fired without
initState, which is the whole point of the Element tree from lesson 14: same
type, same key, so the State was reused and only the widget was replaced.
didUpdateWidget, which is the one that gets forgotten
The trap is a widget that does setup based on a constructor argument.
initState reads widget.userId and starts a fetch. The parent
then rebuilds with a different userId. initState does not run
again, because it is the same State — so the screen shows the old user forever. The fix is
to compare and react:
if (old.userId != widget.userId) _load(widget.userId);— guard on the field you care about, not on the widget as a whole.- Always call
super.didUpdateWidget(old). Some framework mixins depend on it. - You may call
setStateinside it, but you rarely need to: a build is already scheduled.
Work in build, which is the mistake that scales badly
build is called whenever anything above it changes. Starting a fetch there
is not a slow fetch, it is a fetch per frame. The difference is countable.
mounted, and the crash it prevents
Every await in a State method is a place the user can leave.
When they do, the State is disposed while your future is still in flight, and the
setState that follows arrives at an object that is no longer in the tree.
mounted is the framework asking you to check.
mounted is a guard, not a design. It stops the exception; it does not
stop the work. The request still completed, the JSON was still parsed, the bytes were
still paid for. If the user can leave, the right answer is usually to cancel — a
CancelToken, a StreamSubscription you cancel in
dispose, or a flag the operation itself checks. Reach for
mounted when cancellation genuinely is not available.
The version of this that impresses is the pairing. Every initState line
should have a matching dispose line, and you should be able to point at
both. Controller created, controller disposed. Subscription opened, subscription
cancelled. Timer started, timer cancelled. When someone asks how you find leaks, that
pairing is the answer before any tool is.