RenderObject: Below Widgets
This is Part 6 — the final content part of the Flutter Internals series (the question bank is next). We've worked down through widgets, elements, keys, context, lifecycle, and slivers. Now we reach the bottom: the RenderObject — the third tree from Part 1, where layout is computed, pixels are painted, and taps are tested.
Most developers never touch this layer directly, and that's fine — Flutter's widgets cover almost everything. But understanding it is what separates "I arrange widgets" from "I understand how Flutter decides every size and position on screen." And occasionally you do drop here: a custom layout no Row/Stack/Flex can express, or painting that needs full control. Let's go below widgets.
The capstone of the series — builds on all prior parts, especially Part 1 and layouts/constraints. Flutter 3.38 / Dart 3.12.
Where RenderObjects sit
Recall the three trees (Part 1): Widgets (config) → Elements (instances) → RenderObjects (layout + paint). Not every widget has a render object — composition widgets (StatelessWidgets, Padding, Center) build other widgets, but eventually you reach widgets backed by a real RenderObject that does physical work.
A RenderObject is responsible for three jobs: layout (compute its own size and place its children), paint (draw to the canvas), and hit testing (decide if a tap at a point hits it). The most common base class is
RenderBox— a render object in the familiar 2D cartesian (width × height) model. (Slivers from Part 5 useRenderSliverinstead.)
The layout protocol: the single most important idea
Flutter's layout is famously fast because it's a single-pass algorithm with one rule you can recite in your sleep:
Constraints go down. Sizes go up. The parent sets position.
Analogy — the room negotiation. A parent tells each child: "You may be anywhere from this small to this big" (a BoxConstraints — min/max width and height). The child picks its own size within those limits and reports it back up. Then the parent decides where to place the child (its offset). The child never chooses its own position; the parent never chooses the child's size.
// BoxConstraints flowing DOWN:
BoxConstraints(minWidth: 0, maxWidth: 300, minHeight: 0, maxHeight: 600)
// The child's performLayout picks a Size within them and reports UP:
size = Size(300, 80);
// The PARENT then sets the child's position via parentData (offset).
This is why a Container() with no constraints can be huge or tiny depending on its parent, and why "unbounded constraints" errors happen (a parent offering maxHeight: infinity, like a Column, to a child that wants to fill it, like a ListView). Every layout puzzle in Flutter is this protocol playing out. (Constraints deep-dive.)
performLayout — the heart
A RenderBox implements performLayout: read the incoming constraints, lay out children (passing each its constraints), set their positions, and set its own size.
@override
void performLayout() {
final BoxConstraints constraints = this.constraints; // came DOWN from parent
// Lay out the child with constraints we choose:
child.layout(constraints.loosen(), parentUsesSize: true);
// Place the child (we, the parent, decide position):
(child.parentData as BoxParentData).offset = const Offset(8, 8);
// Choose OUR size within our constraints, reported UP:
size = constraints.constrain(Size(child.size.width + 16, child.size.height + 16));
}
parentUsesSize: truetells the framework our layout depends on the child's size (so the child relaying out forces us to relayout). Omit it when you don't read the child's size — an optimization that limits relayout propagation.
Painting
After layout, paint draws using a Canvas (via PaintingContext) at the offset the parent assigned:
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;
final paint = Paint()..color = color;
canvas.drawRRect(
RRect.fromRectAndRadius(offset & size, const Radius.circular(8)),
paint,
);
// Paint children, offset by their position:
context.paintChild(child, offset + (child.parentData as BoxParentData).offset);
}
Layout and paint are separate phases. Layout decides sizes/positions; paint draws. They're invalidated independently:
markNeedsLayout(size/position changed) vsmarkNeedsPaint(only appearance changed). Changing just a color callsmarkNeedsPaint— no relayout — which is cheaper. This is the same in-place update from Part 1: the render object is reused, just re-painted.
Hit testing
When you tap, Flutter walks the render tree asking each object "is this point inside you?" via hitTest, building the list of render objects under the pointer (which then dispatch the event). A RenderBox hit-tests against its size; custom render objects can override it for non-rectangular shapes.
@override
bool hitTestSelf(Offset position) => true; // the whole box is tappable
How a widget creates a RenderObject
This connects straight back to Part 1's reconciliation. Widgets that own a render object extend a RenderObjectWidget and implement two methods the RenderObjectElement calls:
class ColoredSquare extends LeafRenderObjectWidget {
const ColoredSquare(this.color, {super.key});
final Color color;
@override
RenderObject createRenderObject(BuildContext context) =>
RenderColoredSquare(color); // called ONCE, when the element mounts
@override
void updateRenderObject(BuildContext context, RenderColoredSquare ro) {
ro.color = color; // called on REBUILD when the element is reused (canUpdate)
}
}
This is the payoff of the whole series. When
canUpdate(Part 1) says "reuse the Element," the framework callsupdateRenderObject, which mutates the existing render object in place (here, just setscolorand internallymarkNeedsPaint). It does not create a new one. That's precisely why rebuilding widgets is cheap — the expensive render object persists and is tweaked. Type/key change → new Element →createRenderObject→ a fresh render object.
Three base widget classes by child count:
| Base widget | Children | Example |
| --- | --- | --- |
| LeafRenderObjectWidget | none | Text (RenderParagraph), Image |
| SingleChildRenderObjectWidget | one | Padding, Opacity, Align |
| MultiChildRenderObjectWidget | many | Row/Column (RenderFlex), Stack |
When (and when not) to write one
Usually you don't. Reach for these before a custom RenderObject:
CustomPaint— custom drawing (charts, signatures, gauges) without custom layout. Covers most "I need to draw something" cases.- Existing layout widgets —
Stack+Positioned,Flex,CustomMultiChildLayout,Flowsolve most custom arrangement needs.
Write a custom RenderBox only when you need custom layout logic that none of those express — e.g. a bespoke flow that measures children and positions them by rules Wrap/Flex can't do.
class RenderColoredSquare extends RenderBox {
RenderColoredSquare(this._color);
Color _color;
set color(Color v) {
if (v == _color) return;
_color = v;
markNeedsPaint(); // appearance changed → repaint only, no relayout
}
@override
void performLayout() {
size = constraints.constrain(const Size(100, 100)); // pick size within constraints
}
@override
void paint(PaintingContext context, Offset offset) {
context.canvas.drawRect(offset & size, Paint()..color = _color);
}
@override
bool hitTestSelf(Offset position) => true;
}
Notice the discipline: the
colorsetter onlymarkNeedsPaint()(not layout) because color doesn't affect size — minimizing work, the efficiency mindset at the lowest level.
Bonus: RepaintBoundary (a render-tree tool you'll actually use)
Even without writing render objects, one render-tree concept pays off constantly: RepaintBoundary isolates a subtree onto its own layer, so when it repaints (e.g. an animation) it doesn't force its neighbors to repaint, and vice versa.
RepaintBoundary(child: SpinningLogo()) // its repaints stay contained
When a small, frequently-animating widget sits among expensive static siblings, wrapping it in a
RepaintBoundarycan cut repaint cost dramatically — the render layer the animation dirties is its own. (This is a headline tip in Flutter performance guidance and connects to the efficiency rules.)
Practice Challenges
Challenge 1 — The mantra. State Flutter's layout rule in one line and say who decides size vs position.
Show solution
Constraints go down, sizes go up, the parent sets position. The child chooses its size within the parent's BoxConstraints; the parent chooses the child's position (offset).
Challenge 2 — Layout vs paint. Changing a widget's color, which invalidation runs — markNeedsLayout or markNeedsPaint — and why does it matter?
Show solution
markNeedsPaint only — color doesn't change size/position, so no relayout is needed. It matters because skipping layout is cheaper; repainting alone is far less work than a full relayout. (Part 1 in-place update.)
Challenge 3 — Reuse path. When a parent rebuilds and canUpdate reuses the Element, which RenderObject method runs, and what does it do?
Show solution
updateRenderObject — it mutates the existing render object in place with the new widget's values (and marks needs-paint/layout as appropriate). No new render object is created; that only happens via createRenderObject when the Element is fresh.
Challenge 4 — Right tool. You need to draw a custom circular progress gauge. Custom RenderBox or CustomPaint? Why?
Show solution
CustomPaint — it's custom drawing with no special layout needs, so a CustomPainter is simpler and sufficient. Reserve a custom RenderBox for cases needing bespoke layout logic existing widgets can't express.
Challenge 5 — Contain the repaint. A tiny spinner animates next to a heavy static chart, and profiling shows the chart repainting every frame. What render-tree tool helps?
Show solution
Wrap the spinner in a RepaintBoundary so its repaints are isolated to its own layer and don't force the chart to repaint. (Confirm in DevTools that the chart's repaints stop.)
Questions to test yourself
Q1 (basic). What three jobs does a RenderObject do?
Show answer
Layout (compute its size and place children), paint (draw to the canvas), and hit testing (determine whether a point hits it). RenderBox is the common 2D base class.
Q2 (basic). State the layout protocol and the data types involved.
Show answer
Constraints down, sizes up, parent sets position. The parent passes BoxConstraints down; the child returns a Size within them; the parent sets the child's offset (position) via parent data.
Q3 (intermediate). Why are layout and paint invalidated separately?
Show answer
Because many changes (e.g. color) affect only appearance, not size/position. markNeedsPaint repaints without a relayout (cheaper), while markNeedsLayout is used when size/position changes. Separating them avoids unnecessary layout work.
Q4 (intermediate). How does updateRenderObject make widget rebuilds cheap?
Show answer
When canUpdate reuses an Element, the framework calls updateRenderObject, which mutates the existing render object in place with new values instead of allocating a new one. The expensive render object persists across rebuilds; only changed properties (and the needed paint/layout) are updated.
Q5 (advanced). When should you write a custom RenderBox versus using CustomPaint or existing layout widgets?
Show answer
Use CustomPaint for custom drawing with no special layout, and existing widgets (Stack, Flex, CustomMultiChildLayout, Flow) for most custom arrangement. Write a custom RenderBox only when you need bespoke layout logic (measuring/positioning children) that none of those can express.
Q6 (advanced). What does a RepaintBoundary do and when does it help performance?
Show answer
It isolates a subtree onto its own paint layer, so that subtree's repaints don't force neighbors to repaint (and vice versa). It helps when a small, frequently-repainting widget (an animation) sits among expensive static siblings — confining the repaint to its own layer cuts wasted painting.
Wrapping up
- RenderObjects (commonly
RenderBox) do layout, paint, and hit testing — the bottom of the three trees. - Layout is one pass: constraints go down (
BoxConstraints), sizes go up (Size), the parent sets position — the source of every sizing puzzle and unbounded-constraint error. - Layout and paint invalidate separately (
markNeedsLayoutvsmarkNeedsPaint); color-only changes just repaint. - Widgets create render objects via
createRenderObject(once) andupdateRenderObject(on reuse, mutating in place) — the reason rebuilds are cheap (Part 1). - Prefer
CustomPaint/existing layout widgets; write a customRenderBoxonly for bespoke layout. UseRepaintBoundaryto isolate costly repaints.
That's the engine, top to bottom. Part 7 is the proving ground: a 100-question mastery bank — hints and solutions — plus 10 coding mini-exercises and a capstone that traces one interaction through every layer of Flutter you now understand.