0Pricing
Flutter Mobile Development · Lesson

The Three Trees: Widget, Element, and RenderObject

Understand how Flutter reconciles widget, element, and render trees during rebuilds.

The Three Trees: Widget, Element, and RenderObject is a free Flutter Mobile Development lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Flutter Mobile Development learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Three Trees?

When you write Flutter UI, you describe widgets. But the framework actually maintains three parallel trees that work together every frame:

  • Widget tree — immutable configuration objects you build in build().
  • Element tree — the mutable, long-lived bridge that holds state and tracks position in the tree.
  • RenderObject tree — the objects that actually do layout, painting, and hit-testing.

Understanding this split is the key to reasoning about rebuild cost, why const widgets are cheap, and why a misplaced Key can corrupt state.

Widgets Are Immutable Blueprints

A Widget is just a lightweight, immutable description of a piece of UI. It holds no mutable state and is cheap to create and discard. Calling build() throws away the old widget objects and produces fresh ones every time.

Because widgets are throwaway, comparing two widgets is fast. Flutter uses that comparison to decide whether the heavier element and render objects can be reused rather than rebuilt.

class Greeting extends StatelessWidget {
  const Greeting({super.key, required this.name});

  final String name;

  @override
  Widget build(BuildContext context) {
    // A brand-new Text widget is created on every rebuild.
    return Text('Hello, $name');
  }
}

Elements Are the Living Tree

An Element is created by a widget via createElement(). Unlike widgets, elements are mutable and long-lived. The element tree is the actual runtime structure Flutter walks every frame.

Each element holds a reference to its current widget. When a rebuild happens, the element is handed a new widget and decides whether to keep itself (update in place) or be replaced. This is where reconciliation happens.

  • StatelessElement — backs a StatelessWidget.
  • StatefulElement — owns the State object, which is why state survives rebuilds.

State Lives in the Element, Not the Widget

This is the crux of why Flutter's design works. The State object is held by the StatefulElement, which persists across rebuilds. The StatefulWidget itself is replaced on every parent rebuild — but the element (and its state) stays.

That is why your counter does not reset when the parent rebuilds: the widget is new, but the element and its State are the same instance.

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

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

class _CounterState extends State<Counter> {
  int _count = 0; // Survives parent rebuilds because the element persists.

  void _increment() => setState(() => _count++);

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: _increment,
      child: Text('Count: $_count'),
    );
  }
}

RenderObjects Do the Heavy Lifting

Not every widget creates a render object. Only RenderObjectWidgets (like Padding, Opacity, Text's internals) produce entries in the render tree. Composition widgets like StatelessWidget and StatefulWidget only orchestrate other widgets — they have no render object.

RenderObjects implement performLayout(), paint(), and hit-testing. They are the most expensive objects, so Flutter works hard to update them in place instead of recreating them.

  • Widget tree: deep (lots of composition widgets).
  • Element tree: same depth (one element per widget).
  • Render tree: shallower (only render-object widgets appear).

Reconciliation: updateChild

The reconciliation algorithm lives in Element.updateChild(oldElement, newWidget). For each child position, Flutter decides among four outcomes based on the new widget and the old element:

  • Update in place — if the widgets can match, reuse the element and render object.
  • Replace — if they can't match, deactivate the old subtree and inflate a new one.
  • Insert — new widget, no old element.
  • Remove — old element, no new widget.

The cheap path (update in place) is what keeps Flutter fast.

The Matching Rule: canUpdate

Whether an element can be reused is decided by the static method Widget.canUpdate(oldWidget, newWidget). The rule is simple but critical:

Two widgets match if and only if their runtimeType and key are equal.

If they match, the existing element keeps its position and render object and just swaps in the new widget's configuration. If they don't match, the old element is discarded and a fresh subtree is inflated — losing any associated State.

// This is the actual decision rule used during reconciliation.
bool canUpdate(Object oldType, Object? oldKey, Object newType, Object? newKey) {
  return oldType == newType && oldKey == newKey;
}

void main() {
  // Same type, same (null) key -> reuse element.
  print(canUpdate('Text', null, 'Text', null)); // true
  // Different type -> rebuild subtree, state is lost.
  print(canUpdate('Text', null, 'Container', null)); // false
  // Same type, different keys -> NOT a match.
  print(canUpdate('Text', 'a', 'Text', 'b')); // false
}

Why const Widgets Skip Rebuilds

When you mark a widget const, the same canonical instance is reused across builds. During reconciliation, updateChild sees that identical(oldWidget, newWidget) is true and can short-circuit the entire subtree — no element update, no render-object work.

This is why adding const to leaf widgets is one of the cheapest performance wins: identical widget references let Flutter prune whole branches from reconciliation.

class Header extends StatelessWidget {
  const Header({super.key});

  @override
  Widget build(BuildContext context) {
    // The const child is identical across rebuilds, so its element
    // subtree is skipped entirely during reconciliation.
    return const Padding(
      padding: EdgeInsets.all(16),
      child: Text('Settings'),
    );
  }
}

Keys Disambiguate Same-Type Siblings

When a list contains multiple siblings of the same type, Flutter matches them by position by default. If you reorder them, the elements (and their state) stay glued to the old position — a classic source of bugs in stateful lists.

A Key changes the matching identity. With keys, reconciliation matches by (runtimeType, key) instead of position, so elements and their State follow the widget to its new slot. Use ValueKey for stable data identity.

class ReorderableTiles extends StatelessWidget {
  const ReorderableTiles({super.key, required this.items});

  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        for (final id in items)
          // ValueKey lets each element's State follow its data on reorder.
          TodoTile(key: ValueKey(id), id: id),
      ],
    );
  }
}

GlobalKey: Moving an Element Across the Tree

A LocalKey (like ValueKey) only disambiguates siblings under the same parent. A GlobalKey is unique across the entire app and lets an element — with its render object and state intact — be moved to a completely different parent without being rebuilt.

GlobalKeys are powerful but costly: they force the framework to track the element globally and trigger a deactivate/reactivate dance. Use them deliberately (e.g., preserving a video player while it changes parents), not as a default.

Profiling the Three Trees

In practice you observe these trees through DevTools and rebuild markers:

  • Flutter Inspector shows the widget tree; toggle to see the render tree with sizes and constraints.
  • Rebuild Stats / RepaintRainbow reveal which subtrees re-run build() or repaint.
  • Wrapping with RepaintBoundary isolates a render-object subtree into its own layer so its repaints don't cascade.

The mental model: too many widgets rebuilding is a reconciliation cost; too many render objects repainting is a raster cost. They are different problems with different fixes.

class IsolatedChart extends StatelessWidget {
  const IsolatedChart({super.key, required this.painter});

  final CustomPainter painter;

  @override
  Widget build(BuildContext context) {
    // RepaintBoundary gives this CustomPaint its own render layer,
    // so frequent repaints here don't dirty the parent's layer.
    return RepaintBoundary(
      child: CustomPaint(painter: painter),
    );
  }
}

Quick Check: Reconciliation Outcome

A StatefulWidget of type EditorPane with no key is at position 0 of a Column. On the next rebuild, the parent returns a PreviewPane (a different type) at position 0 instead. What happens to the original EditorPane's element and State?

Recap: The Three Trees

You now have a working model of how Flutter reconciles UI:

  • Widgets are immutable, throwaway blueprints — cheap to recreate every build.
  • Elements are the mutable, long-lived tree that holds State and runs reconciliation via updateChild.
  • RenderObjects are the expensive layout/paint objects Flutter strives to update in place.
  • Reuse is decided by canUpdate: equal runtimeType and key means update in place; otherwise the subtree (and its state) is rebuilt.
  • const identity short-circuits reconciliation; Keys control sibling identity; GlobalKeys move elements across the tree; RepaintBoundary isolates repaint cost.

With this mental model, performance work becomes precise: minimize unnecessary rebuilds (reconciliation cost) and unnecessary repaints (raster cost) separately.

Frequently asked questions

Is the “The Three Trees: Widget, Element, and RenderObject” lesson free?

Yes — the full text of “The Three Trees: Widget, Element, and RenderObject” is free to read here on the web, and the Flutter Mobile Development course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Flutter Mobile Development course, upgrade to CoddyKit PRO.

What will I learn in “The Three Trees: Widget, Element, and RenderObject”?

Understand how Flutter reconciles widget, element, and render trees during rebuilds. You practise Flutter Mobile Development with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Flutter Mobile Development?

No prior experience is required. Flutter Mobile Development on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “The Three Trees: Widget, Element, and RenderObject” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Flutter Mobile Development lesson?

Yes. Every Flutter Mobile Development lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. The Three Trees: Widget, Element, and RenderObject
  2. Profiling Jank with the DevTools Timeline
  3. RepaintBoundary, Const Widgets, and Rebuild Pruning
  4. Shader Warm-Up and Impeller Migration
← Back to Flutter Mobile Development