← Back to blog
Flutter Fundamentals · Part 2 of 9
July 16, 202610 min read

The Three Trees: Widget, Element & Render Tree in Flutter

FlutterDart

The Three Trees

This is Part 2 of the Flutter Fundamentals series. In Part 1 we ended on a cliffhanger: a widget is just an immutable blueprint, not the thing on screen. So how does a tree of throwaway blueprints become a smooth, 60-fps UI without rebuilding the world every frame?

The answer is the single most important concept for truly understanding Flutter: there isn't one tree, there are three. Widgets, Elements, and RenderObjects. Most "why did my widget lose its state?" and "why is this slow?" questions dissolve once you can see all three. Let's build that mental picture carefully.


Why one tree isn't enough

Flutter rebuilds your UI constantly — every time state changes, build runs and produces a brand-new widget tree. If widgets were the heavy objects that do layout and paint, recreating them 60 times a second would be ruinously slow.

So Flutter splits the job into three cooperating trees, each with one responsibility:

| Tree | What it is | Lifespan | Job | | --- | --- | --- | --- | | Widget | immutable config / blueprint | recreated constantly (cheap) | describe what the UI should look like | | Element | the bridge / bookkeeper | persistent, mutable | hold state & position, decide what changed | | RenderObject | the heavy worker | persistent, mutable | actually lay out, paint, hit-test |

The trick: widgets are disposable, but the Elements and RenderObjects behind them are reused. Throw away a thousand widget blueprints — Flutter keeps the expensive machinery and just updates it.

A blueprint analogy: the Widget is an architect's drawing (cheap to redraw, you do it constantly). The RenderObject is the actual building (expensive — you don't demolish and rebuild it for a paint-color change). The Element is the site manager who holds the current drawing, remembers the building's history, and decides whether a new drawing means "repaint the wall" or "knock it down and build a new one."


Tree #1 — Widgets: immutable blueprints

You already know these — they're what you write. The crucial property is immutability: a widget's fields are final. A widget never changes; when something needs to differ, Flutter creates a new widget.

// A widget is just a lightweight, immutable description.
const Text('Hello', style: TextStyle(fontSize: 20));

Because they're cheap and immutable, Flutter feels free to recreate the entire widget tree on every rebuild. A widget holds no mutable state and does no layout or painting — it only describes.


Tree #2 — Elements: the persistent bridge

When Flutter "mounts" your widget tree for the first time, it walks it and creates one Element per widget by calling widget.createElement(). The Element tree is the living, persistent structure that actually represents your UI in memory between frames.

Each Element:

  • holds a reference to its current widget (the latest blueprint),
  • knows its parent and children (its position in the tree),
  • owns the associated RenderObject (for render widgets) or State (for stateful widgets),
  • and decides, on each rebuild, whether to update, reuse, or replace based on the new widget.

This is where your State lives. When you use a StatefulWidget, the State object is held by the Element, not the widget. That's why your counter value survives even though the widget is rebuilt — the widget is thrown away each frame, but the Element (and its State) persists. We'll lean on this hard in Part 3.

The official docs put the Element tree's role precisely:

The element tree is persistent from frame to frame, and therefore plays a critical performance role, allowing Flutter to act as if the widget hierarchy is fully disposable while caching its underlying representation.

That sentence is the whole game: disposable widgets, cached reality.


Tree #3 — RenderObjects: the heavy lifting

At the bottom are RenderObjects. These are the objects that do the genuinely expensive work:

  • Layout — compute size and position (using the constraints model from Part 6).
  • Painting — draw onto the canvas.
  • Hit testing — figure out which object a tap landed on.

RenderObjects are heavy, so Flutter creates as few as possible and keeps them alive across frames, mutating them in place when the widget changes. Notably, not every widget makes a RenderObject. Widgets like Center, Padding, Opacity are "render" widgets that produce one; but structural widgets like StatelessWidget/StatefulWidget don't — they just build other widgets. So the RenderObject tree is usually shorter than the widget tree.


Putting the three side by side

Consider this tiny tree:

Center(
  child: Text('Hi'),
)

Conceptually Flutter holds three parallel structures:

   WIDGET tree            ELEMENT tree                 RENDER tree
   (blueprints)           (persistent bridge)          (pixels)

   Center        ──→      CenterElement       ──→      RenderPositionedBox
     │                       │                            │
   Text('Hi')    ──→      TextElement         ──→      RenderParagraph

Each Element sits between a widget and its RenderObject, holding both together and keeping its place in the tree across rebuilds.


The magic: what happens on rebuild

Here's where it all pays off. Say setState runs and build produces a new widget tree. Flutter walks the existing Element tree and, at each position, compares the new widget with the old widget the Element currently holds. For each spot it asks a fast question:

Can this Element be updated to the new widget, or must it be replaced?

The rule (simplified) is:

  • Same runtimeType and same key?Reuse the Element. Just hand it the new widget and update the RenderObject's properties in place. Cheap. State is preserved.
  • Different type (or different key)?Deactivate the old Element + its subtree, and build a new Element/RenderObject. Expensive. State is lost.
// Rebuild 1                       Rebuild 2
Center(                            Center(
  child: Text('Hi'),       →         child: Text('Bye'),   // same type → REUSE
)                                  )
//  ↑ TextElement is reused; only the RenderParagraph's text changes.

versus:

// Rebuild 1                       Rebuild 2
Center(                            Center(
  child: Text('Hi'),       →         child: Container(),   // different type → REPLACE
)                                  )
//  ↑ The Text's Element/RenderObject are thrown out; a new one is built.

This element-reconciliation is why a Flutter UI can rebuild thousands of widgets per frame and stay smooth: most rebuilds just update existing Elements and tweak existing RenderObjects — almost nothing heavy is recreated.


Why this explains real bugs

Once you can see the three trees, classic Flutter mysteries become obvious.

"My widget lost its state when I reordered a list"

Because Flutter matches Elements by position and type, if you reorder two same-typed widgets, the Elements (and their State) stay put while the widgets swap — so state appears to stick to the wrong item. The fix is a Key, which tells Flutter's matching algorithm "this is the same logical widget, follow it":

// Without keys, State matches by position and gets attached to the wrong tile.
// A Key lets Flutter track identity across reordering.
children: items
    .map((item) => TodoTile(key: ValueKey(item.id), item: item))
    .toList();

Keys are exactly a hint to the Element-matching step. They only matter when widgets of the same type change position. (More on this in Part 3.)

"Why is context a thing?"

The BuildContext you get in every build(BuildContext context) is the Element. That's why context knows where you are in the tree and can look up ancestors (Theme.of(context), Navigator.of(context)) — the Element is your position in the live tree. We'll use that constantly in Part 4 and Part 8.


You rarely touch two of the three

Day to day, you write widgets and let Flutter manage the Element and Render trees for you. You almost never create an Element by hand, and you'll only write a custom RenderObject for advanced custom-layout/painting work. But understanding that the other two exist — and that Elements persist while widgets are disposable — is what turns Flutter from "magic" into "machine you can reason about."


Practice Challenges

Challenge 1 — Name the responsibilities. Without looking, state the one-line job of each tree: Widget, Element, RenderObject.

Show solution

Widget — an immutable description of the UI (recreated constantly, cheap). Element — the persistent bridge/bookkeeper that holds State and position and decides update-vs-replace. RenderObject — the heavy worker that does layout, painting, and hit testing.

Challenge 2 — Predict reuse vs replace. For each rebuild change, say whether the Element is reused or replaced: (a) Text('a')Text('b'); (b) Text('a')Icon(Icons.star); (c) Padding(child: Text('a'))Padding(padding: ..., child: Text('a')).

Show solution

(a) Reuse — same type (Text), only the RenderObject's text updates. (b) Replace — different type (TextIcon), old Element/RenderObject discarded. (c) Reuse both — same types (Padding, Text); only properties update in place.

Challenge 3 — Where does State live? A StatefulWidget rebuilds every frame, yet its counter persists. Which tree holds the State, and why does that preserve it?

Show solution

The Element holds the State object. Widgets are recreated each rebuild (and discarded), but the Element is persistent across frames — so the State it owns survives even though the widget describing it is thrown away and replaced.

Challenge 4 — Fix the list bug. Reordering items in a ListView makes the wrong rows keep their (stateful) expansion. What's the cause and the one-line fix?

Show solution

Cause: Flutter matches Elements (and their State) by position + type, so reordering same-typed rows keeps the Elements in place while the widgets swap — state sticks to the position, not the item. Fix: give each row a stable Key (e.g. key: ValueKey(item.id)) so Element matching follows logical identity instead of position.


Questions to test yourself

Q1 (basic). Why does Flutter use three trees instead of one?

Show answer

To separate cheap, disposable descriptions (widgets) from the expensive, persistent machinery (Elements and RenderObjects). Widgets can be recreated freely on every rebuild because the Elements and RenderObjects behind them are reused and mutated in place — making frequent rebuilds cheap.

Q2 (basic). Which tree is immutable and recreated constantly?

Show answer

The Widget tree. Widgets are immutable blueprints (all final fields) and are rebuilt every time state changes; they hold no mutable state and do no layout or painting.

Q3 (intermediate). Which tree holds a StatefulWidget's State, and why does that matter?

Show answer

The Element holds the State. It matters because the Element is persistent across frames while the widget is recreated and discarded each rebuild — so storing State on the Element is what lets state survive constant widget rebuilds.

Q4 (intermediate). On rebuild, how does Flutter decide whether to reuse or replace an Element at a given position?

Show answer

It compares the new widget to the old one the Element holds. If the runtimeType and key match, the Element is reused (the RenderObject's properties are updated in place, State preserved). If the type or key differs, the old Element and its subtree are replaced with new ones (State lost).

Q5 (intermediate). Does every widget create a RenderObject? Explain.

Show answer

No. Only "render" widgets (like Padding, Center, Opacity, Text) create RenderObjects. Structural widgets like StatelessWidget/StatefulWidget don't render directly — they just build other widgets. So the RenderObject tree is typically shorter than the widget tree.

Q6 (advanced). What is BuildContext really, and how does that explain Theme.of(context) working?

Show answer

BuildContext is the Element for that widget. Because the Element knows its exact position in the live, persistent tree (its ancestors and descendants), context can walk up the tree to find ancestor widgets — which is how Theme.of(context), Navigator.of(context), and MediaQuery.of(context) locate the nearest matching ancestor. The context is your handle into the Element tree.


Wrapping up

The three trees are the mental model that unlocks Flutter:

  • Widgets are immutable blueprints — cheap, recreated on every rebuild.
  • Elements are the persistent bridge — they hold State and position, and decide reuse vs replace by matching runtimeType + key.
  • RenderObjects do the heavy work — layout, paint, hit-test — and are kept alive and mutated in place.
  • Disposable widgets + cached Elements/RenderObjects = fast rebuilds.
  • BuildContext is the Element; Keys guide Element matching when same-typed widgets move.

We kept saying "State lives on the Element." It's time to make that concrete. In Part 3 we tackle the question every Flutter dev faces daily: Stateless vs Stateful widgets — when to use which, and what actually happens to that State across rebuilds.