Widgets, Elements & RenderObjects

By the end of this lesson you will be able to explain, the way a senior Flutter interviewer expects, what a widget really is, the three trees Flutter keeps behind every screen, what setState actually does, the one rule Flutter uses to decide "reuse or replace", why lists of stateful items need keys, what a BuildContext is, and the exact order of a State object's life — with every claim proved by a real Flutter test.

How this lesson proves things. Every code box on this page lives in a real Flutter test file (verify_flutter/test/f01_test.dart). A widget test is a small program that starts a pretend Flutter screen (800 × 600 pixels, no window), puts widgets on it with tester.pumpWidget(...), and moves time forward one frame with tester.pump(). The // => lines under each box are what the test really printed. You already know Dart from the earlier steps; here we only add Flutter.

1. What a widget really is

A widget is like an order slip in a restaurant: "one table for two, by the window". The slip is not the table. It is a small piece of paper that describes what you want. It is cheap to write, you can write a new one every minute, and you never change a slip once it is written — you write a new slip. The restaurant staff (Flutter) read the slip and arrange the real table.

In Flutter everything you write in a screen — Text, Padding, Column, your own LoginPage — is a widget: a Dart object that holds settings (what text, how much padding, which children). Three facts matter:

A const constructor lets Dart build the object while compiling instead of while running. Dart's rule is that equal const values are canonicalised (only one copy exists). The same const expression always gives back the very same object — and that is what lets Flutter skip work later (section 7). One twist you will meet in interviews: in debug builds (and in flutter test) Flutter quietly gives every const widget a hidden note of where it was written in your code, for the Widget Inspector tool. So two const Text('Hi') written in two different places are two objects in debug, and one object in profile and release builds.

"A widget is the thing on the screen." It is not. A Text widget does not know its size or position, and it can sit in two places at once (the same object can be used twice). If you catch yourself wanting to "change the widget", you actually want to build a new one with different settings — usually by changing some state and letting build() run again.
Widget's == is marked @nonVirtual and means "the very same object" (identity). Flutter relies on this: when the new widget is identical to the old one, nothing below it can have changed, so the whole subtree is skipped. A widget with no const is a brand-new object on every build, even if all its fields are equal.
A widget = an immutable, cheap description (configuration). const widgets are built once and shared; the same const expression always returns the same object.

2. The three trees: widgets, elements, render objects

Think of building a house. The blueprint (widget) says "a door here, 90 cm wide". The site manager (element) stands at that exact spot, remembers what has already been built there and who the neighbours are, and checks each new blueprint against what stands. The builder (render object) actually measures and lays bricks. You can redraw the blueprint every day; the site manager and the wall stay, and only what changed gets rebuilt.

Behind every screen Flutter keeps three trees. A tree here is a family chart: each item has one parent and some children.

  1. Widget tree — the descriptions you write (and the ones their build methods return). Thrown away and rebuilt all the time.
  2. Element tree — the long-lived instances. One element per widget position. An element knows its parent and children, keeps the State object of a StatefulWidget, and decides what to do when a new widget arrives for its spot.
  3. RenderObject tree — the objects that do layout (work out sizes and positions) and paint (draw). Only widgets that really lay out or paint, called RenderObjectWidgets (like Padding, Center, RichText), get one. "Composing" widgets such as Text or your own StatelessWidget only have an element; their build returns other widgets.

In a real app you would write MaterialApp → Scaffold → …, but MaterialApp alone builds dozens of hidden widgets (a Navigator, a Theme, localizations and more). So the animation uses a tiny tree where every single node can be shown — and checked by the test below it.

The same tree, walked by a real test: describe() visits every element and prints widget | element | render object.

Do not expect "one widget = one box on screen". Here 4 widgets made only 3 render objects, and RichText was not even written by us — Text.build made it. In real apps the element tree is usually many times bigger than the code you wrote. That is fine: elements are cheap to keep and are reused for years of frames.
Which element class a widget gets is decided by the widget itself in createElement(): StatelessWidget → StatelessElement, StatefulWidget → StatefulElement (which calls createState()), InheritedWidget → InheritedElement, single-child render widgets → SingleChildRenderObjectElement, multi-child → MultiChildRenderObjectElement. Some framework widgets use private subclasses — Directionality, for example, gets an InheritedElement subclass tuned for "almost everyone depends on me". The render tree's root is a RenderView, which stands for the window.
Widget = description (short-lived). Element = the live instance at a position: holds State, parent and children, and decides reuse. RenderObject = layout + paint, only for RenderObjectWidgets.

3. build(), setState and the reuse rule

Every day the site manager receives a fresh blueprint for their spot. Their rule is simple: "Is this still a door, with the same label on it? Then keep the door and just repaint it. Is it now a window? Tear the door out and build a window." Flutter's version of that rule is one line of code.

The rule is Widget.canUpdate(oldWidget, newWidget). It is true when the two widgets have the same runtimeType (the same class) and the same key (two missing keys count as the same). If it is true, the element is kept and given the new widget (update); if false, the old element and everything under it are removed and a new one is made from scratch.

What does setState do? It does not rebuild anything right away. It runs your little function (so the field changes), then marks this widget's element as dirty ("needs a rebuild") and asks for a new frame (one picture on screen; about 60 per second). At the start of that next frame, Flutter rebuilds every dirty element, parents first.

Now a full rebuild. Screen shows a counter; when the count is odd the middle child is a Padding instead of a SizedBox, and the last child is a const Footer() that counts how often its build runs. Watch the element decide, child by child, using canUpdate:

"setState rebuilds the whole app." No — only the element whose State called it, plus whatever that element's build returns that is not identical to last time. And several setState calls before the next frame still cause one rebuild. Also, never call setState just to "force a refresh" with nothing changed: it still costs a full build of that subtree.
Inside, setState calls _element.markNeedsBuild(). That adds the element to the BuildOwner's dirty list and schedules a frame. In the frame, BuildOwner.buildScope sorts dirty elements by depth (parents before children) and calls rebuild() on each one still dirty. A child that already got rebuilt because its parent rebuilt is no longer dirty and is skipped — interview question q28 proves this. After building come layout and paint, only for render objects marked as needing them.
canUpdate = same runtimeType AND same key → keep the element and update it; otherwise replace the subtree. An identical (same object) child is skipped entirely. setState = mark dirty + schedule a frame; the rebuild happens in that frame.

4. Keys: keeping State with the right item

Children in a school photo stand in a row. The photographer remembers "the child in seat 1 wears glasses". If the first child goes home and everyone shuffles one seat left, the photographer — who only remembers seats — now thinks the child in seat 1 (a different child) wears glasses. A key is a name badge: now the photographer remembers "Asha wears glasses", whatever seat she is in.

Inside a Column, Row or ListView, Flutter matches old children to new children with canUpdate. If every child is the same type and has no key, they all match each other, so Flutter simply pairs them by position: old child 0 with new child 0, and so on. For stateless children that is perfect. For stateful children it is a classic bug: the State (a ticked checkbox, typed text, a running animation) stays at the position while the items move.

Our test widget Tile remembers, in its State, which label it was created for (createdFor), like a checkbox that was ticked for that item. After a change we ask every tile: which label do you show, and which State do you carry?

Try it yourself. Type a list of labels, a bar |, and one change. The animation runs Flutter's real matching steps (the same algorithm as the framework's updateChildren, written in JavaScript on this page) twice: once without keys, once with ValueKey(label). The test file checks that real Flutter gives exactly these results.

Which key to choose? A key is only compared with its siblings (children of the same parent), using ==:

A GlobalKey moving a counter from the left slot to the right slot — and the same move with no key:

Three key mistakes: (1) using the list index as the key (ValueKey(i)) — after a removal the indexes shift, so it is exactly as wrong as no key; (2) two siblings with the same key — Flutter throws "Duplicate keys found." (question q26); (3) GlobalKey() or UniqueKey() created inside build — a different key every frame, so the State is destroyed and recreated on every rebuild (question q24). Create a GlobalKey once, in a State field.
What a GlobalKey costs. Every GlobalKey in use is stored in a table owned by the BuildOwner. When a keyed widget appears somewhere new in the same frame as it disappears from its old spot, Flutter takes the old element out of its "inactive" list and moves it — deactivate() then activate() on its State — instead of creating a new one. If it stays away for a whole frame, it is disposed (question q23). That bookkeeping, plus having to keep keys unique across the whole app, is why you use GlobalKeys rarely: for moving a subtree between parents, or for Form/Navigator style access to a child's State.
No keys → children are matched by position, so State stays at the position. Give stateful list items a stable ValueKey(id) and the State follows the item. Keys only need to be unique among siblings, except GlobalKeys, which are unique app-wide and allow reparenting.

5. BuildContext is the Element

A BuildContext is your home address in the tree. With it you can ask "who are my parents?", "where is the nearest post office?" (the nearest Theme), and — once you move out — the address no longer leads to you.

The context handed to build(BuildContext context) is not a separate object: it is the element of that widget. BuildContext is just the part of Element that Flutter lets you use.

With a context you can look upward. context.findAncestorWidgetOfExactType<T>() walks parent by parent until it finds a T — the cost grows with the depth. For data that many widgets need (theme, text direction, screen size), Flutter uses an InheritedWidget: every element carries a small hash map (a table you look up by name in one step) from "inherited widget type" to "the nearest one above me". So context.dependOnInheritedWidgetOfExactType<T>() is one lookup however deep you are, and it also registers a dependency: when that inherited widget changes, this element is rebuilt. That is why Theme.of(context) and MediaQuery.of(context) are cheap to call in every build.

One more thing a context can tell you: whether it is still in the tree. After an await (an async gap), the user may have left the screen, the element is gone, and using its context or calling setState is an error. Check mounted (inside a State) or context.mounted (anywhere, Flutter 3.7 and later) right after the await:

Two traps. (1) getInheritedWidgetOfExactType reads the value without a dependency, so the widget will not rebuild when it changes — the Peeker above still shows peek 1. Use it only in callbacks (like a button press) where you want the current value once. (2) Using context after an await without a mounted check: the analyzer rule use_build_context_synchronously warns you about exactly this.
Each element's map of inherited elements is passed down from its parent when the element is mounted (a persistent hash map, so sharing it costs almost nothing). dependOnInheritedWidgetOfExactType looks itself up in that map and adds the caller to the inherited element's dependents set. When a new inherited widget arrives, Flutter calls updateShouldNotify(old); only if it returns true does every dependent get didChangeDependencies() and a rebuild. Children in between that are const are not rebuilt at all — the notice goes straight to the dependents (question q17).
context = the Element. Upward walks cost the depth; inherited lookups are one hash-map step and register a dependency (rebuild on change, if updateShouldNotify says so). After any await, check mounted / context.mounted before touching context or calling setState.

6. The StatefulWidget lifecycle

A State object lives like a shop: it is opened once (initState), told when the neighbourhood changes (didChangeDependencies), gets new instructions from head office (didUpdateWidget), redecorates as often as needed (build), may be moved to a new street (deactivate → activate), and finally closes for good (dispose).

A StatefulWidget is two objects: the widget (immutable, replaced all the time) and its State (long-lived, owned by the element). The State's methods are called in a fixed order:

  1. createState() — the element asks the widget for a new State object (once per element).
  2. initState() — once. Create controllers, start listening. widget and context exist, but you may not depend on inherited widgets yet.
  3. didChangeDependencies() — right after initState, and again whenever an inherited widget you depend on changes. Safe place for Theme.of(context)-style lookups that feed state.
  4. build() — whenever needed. Must be fast and have no side effects.
  5. didUpdateWidget(oldWidget) — the parent rebuilt and gave this element a new widget (same type and key). Compare oldWidget with widget and react. A build always follows.
  6. setState() → build() in the next frame.
  7. deactivate() — removed from the tree (maybe only for this frame, if a GlobalKey moves it; then activate()).
  8. dispose() — gone for good. Release controllers, cancel subscriptions and timers. After this, mounted is false.

The most common lifecycle job: whatever you create in the State, you dispose in dispose(). Using a controller after it is disposed is caught by Flutter in debug mode:

Classic lifecycle bugs: (1) setState after dispose — an await finished after the user left; guard with if (!mounted) return;. (2) Heavy work in build — parsing JSON, sorting a big list or starting a network call inside build repeats it on every rebuild (60 times a second during an animation); do it once in initState or when the input changes in didUpdateWidget. (3) Forgetting to dispose TextEditingController, AnimationController, ScrollController, StreamSubscriptions and Timers — they keep listeners alive and leak memory. (4) Reading inherited widgets in initState — throws in debug; use didChangeDependencies (question q11).
Look at step 4 of the test above: the inherited Score changed but the Probe widget was the same object (probeB), so there was no didUpdateWidget — only didChangeDependencies then build. If the parent had written const Probe(label: 'b') again in a new place, debug mode would see a different object (section 1) and call didUpdateWidget(b -> b) as well. didUpdateWidget means "a new widget object arrived", not "something in it changed".
Order: createState → initState → didChangeDependencies → build; then didUpdateWidget → build (parent rebuilt), setState → build, didChangeDependencies → build (inherited changed); finally deactivate → dispose. Create in initState, dispose in dispose, check mounted after every await.

7. Rebuild cost: const and splitting widgets

If one light bulb in a room needs changing, you do not repaint the whole house. Keep the part that changes small, and tell the painter "these walls are finished" (const) so they are skipped.

A rebuild costs one build call for the element that was marked dirty, plus one for every child widget that is not identical to last time, all the way down. Two cheap habits cut that cost:

Do not chase rebuild counts blindly. A rebuild of a few small widgets costs microseconds; making everything const and splitting into tiny widgets everywhere can make code harder to read for no visible gain. Measure first (Flutter DevTools shows rebuild counts and frame times), then fix the subtrees that rebuild a lot and are big.
Painting is a separate cost. Even when no widget rebuilds, a render object can need repainting (an animation, a blinking cursor). RepaintBoundary puts its child on its own layer so that child can repaint without repainting its neighbours. It costs memory for the extra layer, so it is a tool for specific hot spots — a later step covers layers, layout and painting in depth.
Rebuild cost = the dirty element + every non-identical child below it. Use const for static children and push state down into small widgets. Painting cost is separate; RepaintBoundary isolates repaints.

Quiz

Interview questions

Cheat sheet

ConceptFact
WidgetImmutable, cheap description. == is identity. Never on screen by itself.
const widgetBuilt at compile time; the same const expression returns the same object, so Flutter skips it on rebuild. Debug builds tag each const widget with its source spot.
ElementLive instance at a tree position; holds State, parent/children; BuildContext is the element.
RenderObjectLayout + paint; only for RenderObjectWidgets (Padding, Center, RichText…). Root: RenderView.
Widget.canUpdateSame runtimeType AND same key → keep element, update(); else unmount and create new.
Identical childSkipped completely: no update, no build.
setStateRuns the callback, marks the element dirty, schedules a frame. Rebuild happens next frame, once, parents first.
Children matchingTop scan, bottom scan, keyed middle by hash map, unkeyed middle dropped. O(n).
No keysMatched by position: State stays at the position (the reorder bug).
ValueKey / ObjectKey / UniqueKey== on the value / identical object / equal only to itself.
GlobalKeyUnique app-wide; reparent within one frame keeps State (deactivate → activate); currentState/currentContext. Create once, never in build.
Inherited lookupdependOnInheritedWidgetOfExactType: one hash-map step + dependency. getInherited…: no dependency. findAncestor…: walks, O(depth).
updateShouldNotifyReturn true only when dependents must rebuild.
Async gapAfter await: if (!mounted) return; or if (!context.mounted) return;
LifecyclecreateState → initState → didChangeDependencies → build → (didUpdateWidget / setState / dependency change → build)* → deactivate → dispose.
Rebuild costconst children, split changing parts into small widgets, no heavy work in build; RepaintBoundary for paint hot spots.