Skip to content

Widgets and the Tree

Flutter builds UI by nesting widgets, not subclassing them. You do not extend a Button to add padding — you wrap it in a Padding. A screen is a deep tree of small, single-purpose widgets composed together.

Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Hello'),
ElevatedButton(onPressed: () {}, child: const Text('Tap')),
],
),
),
);
}

This is composition over inheritance taken to the extreme: many tiny widgets, each doing one thing, nested to form the whole. Center, Padding, Column are not visual chrome — they are the layout.

Here is the model that explains almost everything about Flutter’s behavior and performance. There is not one tree — there are three, kept in sync:

flowchart LR
  subgraph W["Widget tree"]
    w1["Padding"] --> w2["Text"]
  end
  subgraph E["Element tree"]
    e1["PaddingElement
(persists)"] --> e2["TextElement
(holds state)"]
  end
  subgraph R["Render tree"]
    r1["RenderPadding
(layout)"] --> r2["RenderParagraph
(paint)"]
  end
  W -.creates.-> E
  E -.creates.-> R
Widget, Element, and Render trees run in parallel
  • Widget — the immutable description you write. Rebuilt constantly and thrown away. Cheap.
  • Element — the living instance of a widget at a position in the tree. It persists across rebuilds, holds the State for stateful widgets, and decides whether a new widget can update it or must replace it.
  • RenderObject — does the actual layout, painting, and hit-testing. This is the expensive layer, and Flutter works hard to reuse it.

When you rebuild, Flutter walks the new widget tree and compares each new widget to the element already there. If the new widget has the same type and key as the old one, the element is updated in place — the render object is reused, only changed properties applied. Only when the type or key differs does Flutter tear down the element and its render object and build fresh.

// Rebuild produces a new Text widget every time...
Text(counter.toString())
// ...but the TextElement and RenderParagraph underneath are REUSED.
// Only the changed string is pushed down. That's why setState is cheap.

This reconciliation — new immutable widgets diffed against persistent elements — is the engine room of Flutter. Keys (a later lesson) exist to control it when the default type-matching gets it wrong.

How does Flutter add behavior like padding to a widget?
Which of the three trees persists across rebuilds and holds State?
When does Flutter reuse an element (and its render object) during a rebuild?
Which layer does the actual layout and painting?