Emmanuel Akinfulubi

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 State lives, 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 rule that decides whether your State survives

ran and analysed
// 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.

A widget test that proves State survives a rebuild but not a key change

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// A widget that records how many times its State was created.
class Counter extends StatefulWidget {
  const Counter({super.key, required this.label});
  final String label;

  @override
  State<Counter> createState() => CounterState();
}

class CounterState extends State<Counter> {
  static int statesCreated = 0;
  int taps = 0;

  @override
  void initState() {
    super.initState();
    statesCreated++;        // counts how often a NEW State was made
  }

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => setState(() => taps++),
      child: Text('${widget.label}: $taps'),
    );
  }
}

void main() {
  testWidgets('State survives a rebuild, and dies when the key changes', (tester) async {
    CounterState.statesCreated = 0;

    await tester.pumpWidget(const MaterialApp(home: Counter(label: 'first')));
    expect(CounterState.statesCreated, 1);

    // Tap twice, so the State holds something worth losing.
    await tester.tap(find.byType(TextButton));
    await tester.pump();
    await tester.tap(find.byType(TextButton));
    await tester.pump();
    expect(find.text('first: 2'), findsOneWidget);

    // Rebuild with a DIFFERENT label but the same type and key.
    // The Element is reused, so the State - and the tap count - survives.
    await tester.pumpWidget(const MaterialApp(home: Counter(label: 'renamed')));
    expect(CounterState.statesCreated, 1, reason: 'no new State should have been created');
    expect(find.text('renamed: 2'), findsOneWidget,
        reason: 'the label changed but the count survived');

    // Now rebuild with a different KEY. Same type, but Flutter treats it as a
    // different widget, so the old Element is destroyed and the count is lost.
    await tester.pumpWidget(
      const MaterialApp(home: Counter(key: ValueKey('new'), label: 'renamed')),
    );
    expect(CounterState.statesCreated, 2, reason: 'a new State should have been created');
    expect(find.text('renamed: 0'), findsOneWidget, reason: 'the count was reset');
  });
}

Result

This ran as a real widget test during the build, and passed.

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, and the clamp that decides every argument

ran and analysed
// 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.

Tight versus loose, measured rather than described

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('what a parent actually hands down', (tester) async {
    late BoxConstraints fromSizedBox;
    late BoxConstraints fromAlign;

    await tester.pumpWidget(
      MaterialApp(
        home: Column(
          children: [
            SizedBox(
              width: 300,
              height: 200,
              child: LayoutBuilder(
                builder: (context, c) {
                  fromSizedBox = c;
                  return const SizedBox.shrink();
                },
              ),
            ),
            SizedBox(
              width: 300,
              height: 200,
              child: Align(
                alignment: Alignment.topLeft,
                child: LayoutBuilder(
                  builder: (context, c) {
                    fromAlign = c;
                    return const SizedBox.shrink();
                  },
                ),
              ),
            ),
          ],
        ),
      ),
    );

    // BoxConstraints(w=300.0, h=200.0)
    // BoxConstraints(0.0<=w<=300.0, 0.0<=h<=200.0)
    debugPrint('from SizedBox: $fromSizedBox');
    debugPrint('from Align:    $fromAlign');

    expect(fromSizedBox.minWidth, 300);
    expect(fromSizedBox.maxWidth, 300);
    expect(fromSizedBox.isTight, isTrue);

    expect(fromAlign.minWidth, 0);
    expect(fromAlign.maxWidth, 300);
    expect(fromAlign.isTight, isFalse);
  });
}

Result

This ran as a real widget test during the build, and passed.

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:

  • Expanded is FlexFit.tight. The child must be exactly its share.
  • Flexible is FlexFit.loose. The child may be up to its share, and is free to be smaller.

The arithmetic of a flex row, checked

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('Expanded forces a size, Flexible only permits one', (tester) async {
    // 300 wide. 60 goes to the fixed box, leaving 240 to split one-to-two.
    await tester.pumpWidget(
      const MaterialApp(
        home: Align(
          alignment: Alignment.topLeft,
          child: SizedBox(
            width: 300,
            height: 40,
            child: Row(
              children: [
                SizedBox(key: Key('fixed'), width: 60, height: 10),
                Expanded(flex: 1, child: SizedBox(key: Key('one'), height: 10)),
                Expanded(flex: 2, child: SizedBox(key: Key('two'), height: 10)),
              ],
            ),
          ),
        ),
      ),
    );

    expect(tester.getSize(find.byKey(const Key('fixed'))).width, 60);
    expect(tester.getSize(find.byKey(const Key('one'))).width, 80);
    expect(tester.getSize(find.byKey(const Key('two'))).width, 160);

    // Same row, but the first child is Flexible and asks for 20. It keeps 20,
    // and the 130 it did not use is simply left empty.
    await tester.pumpWidget(
      const MaterialApp(
        home: Align(
          alignment: Alignment.topLeft,
          child: SizedBox(
            width: 300,
            height: 40,
            child: Row(
              children: [
                Flexible(child: SizedBox(key: Key('small'), width: 20, height: 10)),
                Expanded(child: SizedBox(key: Key('rest'), height: 10)),
              ],
            ),
          ),
        ),
      ),
    );

    expect(tester.getSize(find.byKey(const Key('small'))).width, 20);
    expect(tester.getSize(find.byKey(const Key('rest'))).width, 150);
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

The classic failure, and the classic fix, both proven

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// A header and a list, arranged the way everybody arranges them first.
Widget broken() => MaterialApp(
  home: Column(
    children: [
      const Text('header'),
      ListView(children: const [Text('a'), Text('b')]),
    ],
  ),
);

/// The same tree with one word added.
Widget fixed() => MaterialApp(
  home: Column(
    children: [
      const Text('header'),
      Expanded(child: ListView(children: const [Text('a'), Text('b')])),
    ],
  ),
);

void main() {
  testWidgets('a Column offers unbounded height, and a ListView refuses it', (tester) async {
    // One layout failure cascades into follow-on errors - thirty of them here -
    // so collect them all rather than asking the tester for "the" exception.
    final errors = <String>[];
    final previous = FlutterError.onError;
    FlutterError.onError = (details) => errors.add(details.exception.toString());
    await tester.pumpWidget(broken());
    FlutterError.onError = previous;

    // Vertical viewport was given unbounded height.
    debugPrint('errors: ${errors.length}, first: ${errors.first}');
    expect(errors, isNotEmpty);
    expect(errors.first, contains('unbounded height'));
  });

  testWidgets('Expanded turns unbounded into a number, and it lays out', (tester) async {
    await tester.pumpWidget(fixed());
    expect(tester.takeException(), isNull);
    expect(find.text('header'), findsOneWidget);
    expect(find.text('a'), findsOneWidget);
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

The extra measuring pass, and the number it produces

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Widget bar({required bool intrinsic}) {
  final row = Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      const SizedBox(width: 40, height: 80),
      Container(key: const Key('sidebar'), width: 40),
    ],
  );
  return MaterialApp(
    home: Align(
      alignment: Alignment.topLeft,
      child: intrinsic ? IntrinsicHeight(child: row) : row,
    ),
  );
}

void main() {
  testWidgets('IntrinsicHeight asks the children how tall they want to be', (tester) async {
    // stretch hands every child a tight height. Without the extra pass, that
    // height is the whole 600 the screen offered.
    await tester.pumpWidget(bar(intrinsic: false));
    final stretched = tester.getSize(find.byKey(const Key('sidebar'))).height;
    debugPrint('without IntrinsicHeight: $stretched');
    expect(stretched, 600);

    // With it, the tight height is the tallest child's own 80.
    await tester.pumpWidget(bar(intrinsic: true));
    final measured = tester.getSize(find.byKey(const Key('sidebar'))).height;
    debugPrint('with IntrinsicHeight: $measured');
    expect(measured, 80);
    expect(tester.takeException(), isNull);
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

The reorder bug, produced and then fixed

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// A row that keeps a count of its own taps. The count lives in State, which lives
/// in the Element, which is the thing a reorder can attach to the wrong row.
class Tile extends StatefulWidget {
  const Tile({super.key, required this.name});
  final String name;

  @override
  State<Tile> createState() => _TileState();
}

class _TileState extends State<Tile> {
  int taps = 0;

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => setState(() => taps++),
      child: Text('${widget.name} $taps'),
    );
  }
}

Widget rows(List<String> names, {required bool keyed}) => MaterialApp(
  home: Column(
    children: [
      for (final n in names)
        if (keyed) Tile(key: ValueKey(n), name: n) else Tile(name: n),
    ],
  ),
);

void main() {
  testWidgets('without keys the state stays behind; with keys it moves', (tester) async {
    // No keys. Tap the first row once, then swap the two rows over.
    await tester.pumpWidget(rows(const ['ada', 'bode'], keyed: false));
    await tester.tap(find.text('ada 0'));
    await tester.pump();
    expect(find.text('ada 1'), findsOneWidget);

    await tester.pumpWidget(rows(const ['bode', 'ada'], keyed: false));
    expect(find.text('bode 1'), findsOneWidget, reason: 'the count landed on the wrong row');
    expect(find.text('ada 0'), findsOneWidget);

    // Same list, same swap, with a ValueKey on each row.
    await tester.pumpWidget(rows(const ['ada', 'bode'], keyed: true));
    await tester.tap(find.text('ada 0'));
    await tester.pump();
    expect(find.text('ada 1'), findsOneWidget);

    await tester.pumpWidget(rows(const ['bode', 'ada'], keyed: true));
    expect(find.text('ada 1'), findsOneWidget, reason: 'the count followed its own row');
    expect(find.text('bode 0'), findsOneWidget);
  });
}

Result

This ran as a real widget test during the build, and passed.

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) — compares v by ==. The default choice. Use the domain identifier: an id, not an index.
  • ObjectKey(o) — compares o by 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.

Key identity, and a GlobalKey moving State across the tree

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class Tile extends StatefulWidget {
  const Tile({super.key, required this.name});
  final String name;

  @override
  State<Tile> createState() => TileState();
}

class TileState extends State<Tile> {
  int taps = 0;
  void bump() => setState(() => taps++);

  @override
  Widget build(BuildContext context) => Text('${widget.name} $taps');
}

void main() {
  testWidgets('what each kind of key compares', (tester) async {
    // ValueKey compares the value it holds.
    expect(const ValueKey('ada') == const ValueKey('ada'), isTrue);
    expect(const ValueKey('ada') == const ValueKey('bode'), isFalse);

    // ObjectKey compares identity, so two equal-looking records stay distinct.
    final ada = Object();
    expect(ObjectKey(ada) == ObjectKey(ada), isTrue);
    expect(ObjectKey(Object()) == ObjectKey(Object()), isFalse);

    // UniqueKey equals nothing but itself, so it forces a fresh Element.
    expect(UniqueKey() == UniqueKey(), isFalse);
  });

  testWidgets('a GlobalKey reaches the State, and carries it to a new parent', (tester) async {
    final key = GlobalKey<TileState>();

    await tester.pumpWidget(
      MaterialApp(home: Column(children: [Tile(key: key, name: 'ada')])),
    );
    key.currentState!.bump();
    key.currentState!.bump();
    await tester.pump();
    expect(find.text('ada 2'), findsOneWidget);

    // Move the same widget under a different parent, in a different place.
    await tester.pumpWidget(
      MaterialApp(
        home: Row(
          children: [
            const Text('sidebar'),
            Column(children: [Tile(key: key, name: 'ada')]),
          ],
        ),
      ),
    );
    expect(find.text('ada 2'), findsOneWidget, reason: 'the State moved with the key');
    expect(key.currentState!.taps, 2);
  });
}

Result

This ran as a real widget test during the build, and passed.

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 whole build runs 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 InheritedWidget above you changing, if you asked it for something. Lesson 20.
  • A Listenable you 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.

The const claim, with numbers rather than folklore

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

int constBuilds = 0;
int freshBuilds = 0;

/// Built once as a compile-time constant, so every rebuild hands the framework
/// the identical instance.
class Frozen extends StatelessWidget {
  const Frozen({super.key});

  @override
  Widget build(BuildContext context) {
    constBuilds++;
    return const Text('frozen');
  }
}

/// The same shape, constructed again on every parent rebuild, so the framework
/// sees a new object each time.
class Fresh extends StatelessWidget {
  const Fresh({super.key, required this.label});
  final String label;

  @override
  Widget build(BuildContext context) {
    freshBuilds++;
    return Text(label);
  }
}

class Screen extends StatefulWidget {
  const Screen({super.key});

  @override
  State<Screen> createState() => _ScreenState();
}

class _ScreenState extends State<Screen> {
  int taps = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextButton(onPressed: () => setState(() => taps++), child: Text('taps $taps')),
        const Frozen(),
        Fresh(label: 'fresh'),
      ],
    );
  }
}

void main() {
  testWidgets('a const child is skipped; an identical-looking fresh one is not', (tester) async {
    constBuilds = 0;
    freshBuilds = 0;

    await tester.pumpWidget(const MaterialApp(home: Screen()));
    expect(constBuilds, 1);
    expect(freshBuilds, 1);

    await tester.tap(find.byType(TextButton));
    await tester.pump();
    expect(find.text('taps 1'), findsOneWidget);

    // const: 1  fresh: 2
    debugPrint('const: $constBuilds  fresh: $freshBuilds');
    expect(constBuilds, 1, reason: 'the same instance, so Flutter did not rebuild it');
    expect(freshBuilds, 2, reason: 'a new instance, so it rebuilt');

    await tester.tap(find.byType(TextButton));
    await tester.pump();
    expect(constBuilds, 1);
    expect(freshBuilds, 3);
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

setState at the top versus a builder at the bottom

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

final builds = <String, int>{};
void note(String who) => builds[who] = (builds[who] ?? 0) + 1;

/// A sibling that never reads the number. It is not const, so nothing protects it.
class Sibling extends StatelessWidget {
  const Sibling({super.key, required this.name});
  final String name;

  @override
  Widget build(BuildContext context) {
    note(name);
    return Text(name);
  }
}

/// setState at the top: the whole build method runs again.
class Wide extends StatefulWidget {
  const Wide({super.key});

  @override
  State<Wide> createState() => _WideState();
}

class _WideState extends State<Wide> {
  int n = 0;

  @override
  Widget build(BuildContext context) {
    note('wide.screen');
    return Column(
      children: [
        Text('n $n'),
        TextButton(onPressed: () => setState(() => n++), child: const Text('wide +')),
        Sibling(name: 'wide.sibling'),
      ],
    );
  }
}

/// The same feature, with the number behind a builder that sits next to the Text
/// showing it. Nothing above the builder is rebuilt at all.
class Narrow extends StatefulWidget {
  const Narrow({super.key});

  @override
  State<Narrow> createState() => _NarrowState();
}

class _NarrowState extends State<Narrow> {
  final n = ValueNotifier<int>(0);

  @override
  void dispose() {
    n.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    note('narrow.screen');
    return Column(
      children: [
        ValueListenableBuilder<int>(
          valueListenable: n,
          builder: (context, value, _) {
            note('narrow.counter');
            return Text('n $value');
          },
        ),
        TextButton(onPressed: () => n.value++, child: const Text('narrow +')),
        Sibling(name: 'narrow.sibling'),
      ],
    );
  }
}

void main() {
  testWidgets('a builder low in the tree is the whole optimisation', (tester) async {
    builds.clear();

    await tester.pumpWidget(const MaterialApp(home: Wide()));
    await tester.tap(find.text('wide +'));
    await tester.pump();
    expect(builds['wide.screen'], 2);
    expect(builds['wide.sibling'], 2, reason: 'it never read n, and rebuilt anyway');

    await tester.pumpWidget(const MaterialApp(home: Narrow()));
    await tester.tap(find.text('narrow +'));
    await tester.pump();
    // {wide.screen: 2, wide.sibling: 2, narrow.screen: 1, narrow.counter: 2, narrow.sibling: 1}
    debugPrint('builds: $builds');
    expect(builds['narrow.screen'], 1, reason: 'the screen build never ran again');
    expect(builds['narrow.counter'], 2, reason: 'only the builder ran again');
    expect(builds['narrow.sibling'], 1, reason: 'the sibling was left alone');
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

Why depth is the only number that matters

ran and analysed
// 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. context exists but nothing inherited is safe to read yet.
  • didChangeDependencies — right after initState, and again whenever an InheritedWidget you depend on changes. This is where Theme.of or MediaQuery.of work is safe.
  • didUpdateWidget(old) — the parent rebuilt and handed you a new widget of the same type. Compare old with widget and 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 in initState ends here.

The lifecycle order, recorded and asserted

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

final log = <String>[];

class Probe extends StatefulWidget {
  const Probe({super.key, required this.value});
  final int value;

  @override
  State<Probe> createState() => _ProbeState();
}

class _ProbeState extends State<Probe> {
  @override
  void initState() {
    super.initState();
    log.add('initState');
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    log.add('didChangeDependencies');
  }

  @override
  void didUpdateWidget(Probe old) {
    super.didUpdateWidget(old);
    log.add('didUpdateWidget ${old.value} to ${widget.value}');
  }

  @override
  void dispose() {
    log.add('dispose');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    log.add('build ${widget.value}');
    return Text('${widget.value}');
  }
}

void main() {
  testWidgets('the order these actually fire in', (tester) async {
    log.clear();

    await tester.pumpWidget(const MaterialApp(home: Probe(value: 1)));
    expect(log, ['initState', 'didChangeDependencies', 'build 1']);

    // A new widget of the same type at the same position. No initState this time.
    log.clear();
    await tester.pumpWidget(const MaterialApp(home: Probe(value: 2)));
    debugPrint('on update: $log');
    expect(log, ['didUpdateWidget 1 to 2', 'build 2']);

    // Take it out of the tree.
    log.clear();
    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
    expect(log, ['dispose']);
  });
}

Result

This ran as a real widget test during the build, and passed.

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 setState inside 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.

Four rebuilds, four fetches, one fetch

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

int fromBuild = 0;
int fromInitState = 0;

/// The mistake. build() runs whenever anything above changes, so in production
/// this is a request per frame.
class FetchesInBuild extends StatelessWidget {
  const FetchesInBuild({super.key});

  @override
  Widget build(BuildContext context) {
    fromBuild++;
    return const Text('in build');
  }
}

/// The fix. initState runs once per State, however often the widget is rebuilt.
class FetchesInInitState extends StatefulWidget {
  const FetchesInInitState({super.key});

  @override
  State<FetchesInInitState> createState() => _FetchesInInitStateState();
}

class _FetchesInInitStateState extends State<FetchesInInitState> {
  @override
  void initState() {
    super.initState();
    fromInitState++;
  }

  @override
  Widget build(BuildContext context) => const Text('in initState');
}

class Host extends StatefulWidget {
  const Host({super.key});

  @override
  State<Host> createState() => _HostState();
}

class _HostState extends State<Host> {
  int taps = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextButton(onPressed: () => setState(() => taps++), child: Text('taps $taps')),
        FetchesInBuild(),
        FetchesInInitState(),
      ],
    );
  }
}

void main() {
  testWidgets('work in build runs per frame; work in initState runs once', (tester) async {
    fromBuild = 0;
    fromInitState = 0;

    await tester.pumpWidget(const MaterialApp(home: Host()));
    expect(fromBuild, 1);
    expect(fromInitState, 1);

    for (var i = 0; i < 3; i++) {
      await tester.tap(find.byType(TextButton));
      await tester.pump();
    }

    // build: 4  initState: 1
    debugPrint('build: $fromBuild  initState: $fromInitState');
    expect(fromBuild, 4, reason: 'once per rebuild');
    expect(fromInitState, 1, reason: 'once per State, whatever the rebuilds');
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

The same widget, left early and left alone

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

bool guardTripped = false;

class Loader extends StatefulWidget {
  const Loader({super.key});

  @override
  State<Loader> createState() => _LoaderState();
}

class _LoaderState extends State<Loader> {
  String status = 'loading';

  @override
  void initState() {
    super.initState();
    load();
  }

  Future<void> load() async {
    await Future<void>.delayed(const Duration(milliseconds: 20));
    if (!mounted) {
      guardTripped = true;      // the user left; there is nothing to update
      return;
    }
    setState(() => status = 'done');
  }

  @override
  Widget build(BuildContext context) => Text(status);
}

void main() {
  testWidgets('the mounted guard is what stops setState after dispose', (tester) async {
    guardTripped = false;

    await tester.pumpWidget(const MaterialApp(home: Loader()));
    expect(find.text('loading'), findsOneWidget);

    // Leave the screen before the await finishes.
    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
    await tester.pump(const Duration(milliseconds: 50));
    expect(guardTripped, isTrue, reason: 'the guard caught a State that was gone');
    expect(tester.takeException(), isNull, reason: 'no setState-after-dispose error');

    // Stay, and the value arrives as normal.
    guardTripped = false;
    await tester.pumpWidget(const MaterialApp(home: Loader()));
    await tester.pump(const Duration(milliseconds: 50));
    expect(find.text('done'), findsOneWidget);
    expect(guardTripped, isFalse);
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.