Flutter's Holy Trinity
The most important objects in Flutter

Flutter development is based on a unique relationship between three special objects: Widgets, Elements, and RenderObjects. I call this Flutter’s Holy Trinity.

To understand Widgets, Elements, and RenderObjects, you must first understand the Flutter Pipeline at a high level. If you understand Flutter’s Pipeline, then you can continue to read about these three special types of objects.

Widgets are what allow you to write simple, declarative code that renders a UI.

RenderObjects handle all of the real UI work, like layout, painting, and hit testing.

Elements handle all the organizational responsibilities related to connecting Widgets to RenderObjects, and tree traversal, such as looking up InheritedWidgets.

Render Objects

RenderObjects are the most foundational piece of the Holy Trinity. In fact, you could build an entire UI with just RenderObjects - no Widgets or Elements needed. But the downside of a RenderObject UI is that all of your UI code would be imperative. You would need to explicitly add children, remove children, run layout, run paint, etc. A pure RenderObject UI would feel a lot like coding for Android and iOS.

// A barebones RenderObject structure that declares the most 
// important RenderObject methods.
class RenderMyThing extends RenderBox {
  @override
  void performLayout() {
    // TODO:
  }

  @override
  bool hitTest(HitTestResult result, {Offset? position}) {
    // TODO:
  }

  @override
  void paint(PaintContext context, Offset position) {
    // TODO:
  }

  @override
  void insertChild(RenderObject child, Slot? slot) {
    // TODO:
  }

  @override
  void moveChild(RenderObject child, Slot? slot) {
    // TODO:
  }

  @override
  void removeChild(RenderObject child, Slot? slot) {
    // TODO:
  }
}

Learn more about RenderObject responsibilities.

Widgets

It’s the current year, so we don’t want imperative UI, we want declarative UI. This means adding Widgets. Widgets are glorified data structures, which collect a number of properties that are used to configure an associated RenderObject.

// A hypothetical Widget, which takes in a `color` and `shape` property, and passes them
// to an associated `RenderObject`.
class MyThing extends RenderObjectWidget {
  const MyThing({
    required this.color,
    required this.shape,
  });

  final Color color;
  final BoxShape shape;

  @override
  RenderMyThing createRenderObject(BuildContext context) {
    return RenderMyThing(
      color: color,
      shape: shape,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderMyThing renderObject) {
    renderObject
      ..color = color
      ..shape = shape;
  }
}

Elements

It’s easy for a Widget to create a RenderObject and pass over a few properties, so why do we need this thing called an Element?

Recall that Widgets never change - they're immutable. When we want a Widget property to change, we don't mutate the existing Widget, we throw that Widget away and replace it with an entirely new Widget. But, RenderObjects are expensive. We can't afford to throw away RenderObjets. So what happens to the RenderObject that a Widget creates? To solve this problem, Flutter provides us with Elements.

An Element is a long-lived object, which associates a Widget with a RenderObject. Importantly, the Widget that’s associated with the RenderObject can be replaced with new Widgets, over time. The Element is responsible for switching out the old Widget with the new Widget, and updating the RenderObject based on the new Widget.

Like RenderObjects, Elements are also initially created by Widgets.

class MyThing extends RenderObjectWidget {
  const MyThing({
    required this.color,
    required this.shape,
  });

  final Color color;
  final BoxShape shape;

  // Creates the `Element` that will hold this `Widget`. That `Element` will 
  // also switch out this `Widget` for different configurations of this `Widget` 
  // in the future, such as when `color` changes from red to green.
  @override
  MyThingElement createElement() => MyThingElement();

  // When the `Element` is first created, it calls this method to create the
  // associated `RenderObject`.
  @override
  RenderMyThing createRenderObject(BuildContext context) {}

  // When a new `Widget` is paired with the `Element`, and a `RenderObject` already exists
  // for the `Element`, the `Element` calls this method to update the `RenderObject`, which 
  // was created earlier with `createRenderObject()`. For example, when the `color`
  // goes from red to green.
  @override
  void updateRenderObject(BuildContext context, RenderMyThing existingRenderObject) {}
}

You might wonder who calls createElement() on a Widget to get this whole dance started. That would be Flutter’s BuildOwner, which sits on top of all Widgets, Elements, and RenderObjects. The BuildOwner gets the process started, and does some internal accounting related to caching and re-using Elements when the Widget tree rebuilds in different configurations. The internal details of the BuildOwner are beyond the scope of this guide.

We’ve seen a barebones representation of a RenderObject and a Widget. What does an Element look like?

class MyThingElement extends RenderObjectElement {
  // TODO:
}

TODO