Implementing a Custom Slotted-Children Element and Render Object#
This guide walks through implementing a custom widget that manages a fixed set of named child slots — a header and a body, or a leading, primary, and trailing widget, or any similar layout where each child has a distinct semantic role rather than a position in a list.
The good news is that the Flutter framework provides nearly everything you need. SlottedMultiChildRenderObjectWidget
and SlottedContainerRenderObjectMixin handle the wiring; you mostly just declare your slots and your children. We'll focus entirely on child management; layout and paint are out of scope.
What This Guide Covers#
- When Slots Are the Right Model — How to recognize that a slotted layout fits your needs better than a list of children.
- The Pieces Involved — The widget, element, and render object, and which the framework provides.
- Defining the Slot Enum — The named identifiers your slots will use.
-
Extending
SlottedMultiChildRenderObjectWidget— Declaring slots and exposing the child for each one. -
Mixing In
SlottedContainerRenderObjectMixin— Letting your render object access children by slot. - How the Pieces Connect — Where the framework hands off between widget, element, and render object.
- What You Get For Free — Reconciliation, mounting, updating, render-tree attachment, and reparenting, all handled for you.
- Common Pitfalls — The small set of mistakes that slotted setups still produce.
When Slots Are the Right Model#
Slots are the right model when your widget has a fixed set of children, each with a distinct role, exposed as separately-typed widget properties rather than as a list. Some signs:
-
Your widget API looks like
MyLayout(header: ..., body: ..., footer: ...)rather thanMyLayout(children: [...]). - Each child has a different meaning to the render object — header is painted at the top, footer at the bottom, body fills the middle.
- The number of slots is fixed at compile time. There's no "list of headers"; there's exactly one header or none.
If your children are interchangeable list elements (a row, a column, a stack), slots are the wrong model — use a multi-child setup instead. If your children are all the same type but logically grouped into a fixed structure (a
leading and a trailing icon, for example), slots are a good fit even when both happen to be icons.
The Pieces Involved#
A slotted setup has three parts:
- A widget that exposes each slot as a separate property.
- An element that reconciles each slot independently.
- A render object that holds one child per slot.
The framework provides the element for free through SlottedMultiChildRenderObjectWidget.createElement, which returns a
SlottedRenderObjectElement. You'll almost never subclass the element yourself.
The framework also provides a render-object mixin, SlottedContainerRenderObjectMixin, that handles slot-by-slot child storage on the render-object side.
What you write is: a slot identifier (usually an enum), the widget (extending a framework base class), and the render object (mixing in a framework mixin).
Defining the Slot Enum#
Slots need identifiers. The convention is to use an enum:
enum MySlot { header, body, footer }
Any value type works as a slot identifier — strings, integers, custom objects — but an enum is conventional because it documents the fixed set of slots at the type level. The framework will use these values to identify each slot when reconciling children and when calling back to attach or detach render objects.
Extending SlottedMultiChildRenderObjectWidget#
SlottedMultiChildRenderObjectWidget is the base class for widgets with named slots. It's parameterized by your slot type and the child render-object type:
class MyLayout extends SlottedMultiChildRenderObjectWidget<MySlot, RenderBox> {
const MyLayout({
super.key,
this.header,
this.body,
this.footer,
this.spacing = 0.0,
});
final Widget? header;
final Widget? body;
final Widget? footer;
final double spacing;
@override
Iterable<MySlot> get slots => MySlot.values;
@override
Widget? childForSlot(MySlot slot) {
switch (slot) {
case MySlot.header:
return header;
case MySlot.body:
return body;
case MySlot.footer:
return footer;
}
}
@override
RenderMyLayout createRenderObject(BuildContext context) {
return RenderMyLayout(spacing: spacing);
}
@override
void updateRenderObject(BuildContext context, RenderMyLayout renderObject) {
renderObject.spacing = spacing;
}
}
The two overrides specific to slotted widgets are:
-
slots— An iterable of all slot identifiers. UsuallyMySlot.valuesfor an enum. -
childForSlot(slot)— Given a slot identifier, return the widget for that slot (ornullif the slot is currently empty).
The element uses these two methods together to reconcile each slot. It iterates over slots, calls
childForSlot for each, and reconciles the result against whatever child element currently occupies that slot. Each slot is reconciled independently, with no cross-slot interactions.
You don't override createElement. The base class returns a SlottedRenderObjectElement
automatically.
Mixing In SlottedContainerRenderObjectMixin#
Your render object holds one child per slot and needs methods to attach, detach, and look up children by slot.
SlottedContainerRenderObjectMixin provides all of this.
class RenderMyLayout extends RenderBox
with SlottedContainerRenderObjectMixin<MySlot, RenderBox> {
RenderMyLayout({double spacing = 0.0}) : _spacing = spacing;
double _spacing;
double get spacing => _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
// Convenience getters for cleaner layout/paint code.
RenderBox? get header => childForSlot(MySlot.header);
RenderBox? get body => childForSlot(MySlot.body);
RenderBox? get footer => childForSlot(MySlot.footer);
}
The mixin provides:
-
childForSlot(MySlot slot)— Returns the current child render object for the given slot, ornullif the slot is empty. -
A default
visitChildrenthat visits every slot's child in slot-enum order. - Render-tree adoption and dropping when slots are populated or cleared.
- Attach and detach propagation to children.
You don't write any of this. You just read children by slot inside your layout, paint, hit-test, and other methods.
The two type parameters mirror the widget's: the slot type (MySlot) and the child render-object type (RenderBox).
How the Pieces Connect#
When your widget first appears in the tree:
- Flutter creates a
MyLayoutwidget instance. -
The framework calls
createElement, which (via the base class) returns a newSlottedRenderObjectElement. -
The element mounts and calls
createRenderObject, which returns aRenderMyLayout. -
The element iterates over
widget.slots. For each slot, it callswidget.childForSlot(slot)and reconciles the result against the (currently nonexistent) child for that slot. -
For each non-null slot widget, the element creates a child element, mounts it, and triggers
insertRenderObjectChild, which routes the child render object to the correct slot in the render object via the mixin's storage.
On rebuilds:
- The framework gives the existing element a new
MyLayoutwidget. -
The element calls
updateRenderObject, forwarding non-child properties likespacing. -
The element iterates over
widget.slotsagain, callingchildForSlotfor each. Each slot is reconciled independently withupdateChild. -
Slot-by-slot updates produce slot-by-slot render-tree mutations through
insertRenderObjectChild,removeRenderObjectChild, andmoveRenderObjectChild(though the last is essentially never triggered for slotted setups, since children don't move between slots).
The key property of slotted reconciliation is that each slot is independent. A slot that contains a stateful widget keeps its state across rebuilds as long as that slot's widget remains compatible (same runtime type, same key). Changing the widget in the
header slot doesn't affect the element in the body slot.
What You Get For Free#
Because you're extending the framework's base classes, you don't write:
-
Mount logic — The element's
mountmethod already creates initial child elements for each slot. -
Update logic — The element's
updatemethod already reconciles each slot independently. -
updateChildcalls — Already invoked once per slot at the right moments. -
insertRenderObjectChild/removeRenderObjectChild— Already wired to call into the mixin's slot storage. -
visitChildren/forgetChild— Already implemented correctly across all slots. -
Render-tree child adoption and dropping — Handled by
SlottedContainerRenderObjectMixin. - Attach/detach propagation — Also handled by the mixin.
- GlobalKey reparenting — Handled by the framework's element implementation.
What you provide is:
- A slot enum (or other slot identifier type).
-
A widget that extends
SlottedMultiChildRenderObjectWidgetwithslotsandchildForSlotoverrides. -
A render object that uses
SlottedContainerRenderObjectMixinand reads children viachildForSlot. createRenderObjectandupdateRenderObjectfor non-child widget properties.
Common Pitfalls#
Forgetting a slot in childForSlot. If you add a new slot to your enum but don't handle it in the
switch, you'll either get a static analysis warning (good) or a silent null
return (less good). A switch over an enum without a default case will surface missing cases at analysis time — prefer that pattern over a switch with a default.
Returning the same widget from multiple slots. Each slot must produce a distinct widget instance. Returning the same
Widget object from two slots will cause the framework to try to mount it twice, which fails. If you want to repeat content, use separate widget instances or a factory.
Treating slots as positional. The order of slots in the enum doesn't determine paint order, layout order, or anything else visual. That's entirely up to your render object's layout and paint code. The slot identifier is just a name — what each slot means visually is whatever your render object decides.
Trying to add or remove slots at runtime. The set of slots is fixed by your enum and your
slots getter. You can't conditionally add a "side panel" slot only when some flag is true — instead, always declare the slot and return
null from childForSlot when it should be empty. The framework treats a null
widget as "this slot is empty," which is what you want.
Forgetting that empty slots are normal. It's common for a layout to be designed with three slots but used with only the body filled in. Your render object's layout code needs to handle each slot being potentially null, since slots can be empty independently. Reading
childForSlot(MySlot.header) returning null is not an error — it just means there's no header right now.
Reading children in the render object's constructor. Like with single-child setups, children aren't attached during the constructor. Read them in layout, paint, or hit-testing methods.
Defining slots incorrectly. The slots iterable must be stable and complete — it should return the same set of slot values every time, in a consistent order, regardless of the current widget configuration. Don't filter it based on whether each slot has a child; return all slot values and let
childForSlot express which ones are currently populated.
That's the complete slotted-children setup. A slot enum, a widget that extends SlottedMultiChildRenderObjectWidget
and tells the framework what slots exist and what's in each, and a render object that mixes in SlottedContainerRenderObjectMixin
and reads children by slot. The framework handles all the reconciliation, attachment, and lifecycle work.