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.
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
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:
- Immutable (cannot be changed after it is made): all fields of a widget are
final. To show something different, Flutter gets a new widget object. - Cheap: a widget is just a few fields. Making hundreds of them 60 times a second is normal — Dart's memory manager is built to clean up short-lived objects very quickly.
- Not on screen: a widget never draws, measures or remembers anything by itself. Something else does that — the next section shows what.
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.
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.== 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.const widgets are built once and shared; the same const expression always returns the same object.2. The three trees: widgets, elements, render objects
Behind every screen Flutter keeps three trees. A tree here is a family chart: each item has one parent and some children.
- Widget tree — the descriptions you write (and the ones their
buildmethods return). Thrown away and rebuilt all the time. - Element tree — the long-lived instances. One element per widget position. An element knows its parent and children, keeps the
Stateobject of aStatefulWidget, and decides what to do when a new widget arrives for its spot. - 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 asTextor your ownStatelessWidgetonly have an element; theirbuildreturns 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.
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.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.3. build(), setState and the reuse rule
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:
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.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.4. Keys: keeping State with the right item
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 ==:
ValueKey(value)— "same if the values are equal (==)". Use a stable id:ValueKey(todo.id). The most common choice.ObjectKey(object)— "same only if it is the very same object". Use when the item has no id and no==, but the same object stays in the list.UniqueKey()— equal only to itself. Creating one insidebuildgives a new key every build, so the element is thrown away every time. Use it on purpose to force a fresh State.GlobalKey— unique in the whole app, not just among siblings. It lets a subtree move to a different parent and keep its State, and lets you reach it withkey.currentState/key.currentContext.
A GlobalKey moving a counter from the left slot to the right slot — and the same move with no 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.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.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
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:
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.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).updateShouldNotify says so). After any await, check mounted / context.mounted before touching context or calling setState.6. The StatefulWidget lifecycle
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:
createState()— the element asks the widget for a new State object (once per element).initState()— once. Create controllers, start listening.widgetandcontextexist, but you may not depend on inherited widgets yet.didChangeDependencies()— right after initState, and again whenever an inherited widget you depend on changes. Safe place forTheme.of(context)-style lookups that feed state.build()— whenever needed. Must be fast and have no side effects.didUpdateWidget(oldWidget)— the parent rebuilt and gave this element a new widget (same type and key). CompareoldWidgetwithwidgetand react. Abuildalways follows.setState()→build()in the next frame.deactivate()— removed from the tree (maybe only for this frame, if a GlobalKey moves it; thenactivate()).dispose()— gone for good. Release controllers, cancel subscriptions and timers. After this,mountedisfalse.
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:
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).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".7. Rebuild cost: const and splitting widgets
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:
- Use
constfor children that never change. Aconstchild returned from the same line is the same object every time, so Flutter skips it and everything below it. - Split widgets: move the part that changes into its own small
StatefulWidget. ThensetStaterebuilds only that small part. (Helper methods likeWidget _buildHeader()do not do this — they run inside the parent's build, so they rebuild with it.)
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.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.const for static children and push state down into small widgets. Painting cost is separate; RepaintBoundary isolates repaints.Quiz
Interview questions
Cheat sheet
| Concept | Fact |
|---|---|
| Widget | Immutable, cheap description. == is identity. Never on screen by itself. |
const widget | Built 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. |
| Element | Live instance at a tree position; holds State, parent/children; BuildContext is the element. |
| RenderObject | Layout + paint; only for RenderObjectWidgets (Padding, Center, RichText…). Root: RenderView. |
Widget.canUpdate | Same runtimeType AND same key → keep element, update(); else unmount and create new. |
| Identical child | Skipped completely: no update, no build. |
setState | Runs the callback, marks the element dirty, schedules a frame. Rebuild happens next frame, once, parents first. |
| Children matching | Top scan, bottom scan, keyed middle by hash map, unkeyed middle dropped. O(n). |
| No keys | Matched by position: State stays at the position (the reorder bug). |
ValueKey / ObjectKey / UniqueKey | == on the value / identical object / equal only to itself. |
GlobalKey | Unique app-wide; reparent within one frame keeps State (deactivate → activate); currentState/currentContext. Create once, never in build. |
| Inherited lookup | dependOnInheritedWidgetOfExactType: one hash-map step + dependency. getInherited…: no dependency. findAncestor…: walks, O(depth). |
updateShouldNotify | Return true only when dependents must rebuild. |
| Async gap | After await: if (!mounted) return; or if (!context.mounted) return; |
| Lifecycle | createState → initState → didChangeDependencies → build → (didUpdateWidget / setState / dependency change → build)* → deactivate → dispose. |
| Rebuild cost | const children, split changing parts into small widgets, no heavy work in build; RepaintBoundary for paint hot spots. |