Available for work
Ankit Ranjan
Back to Deep Dives

The Builder Pattern in Dart — Assembling Objects, One Step at a Time

Some objects are too fiddly to make in one breath. The builder pattern splits assembly from the finished thing — and in Flutter it shows up as the callbacks that build your UI on demand.

June 4, 2026 7 topics 7 quiz questions
Share:
1

The problem — one constructor, too many knobs

Some objects are simple. A Point(x, y) needs two numbers and you're done. But some objects have a dozen knobs, half of them optional, and you have to set them in some order. That's where the single constructor starts to hurt.

Picture a burger order. Patty, cheese, lettuce, sauce, a size, a bun choice. Cram it all into positional parameters and you get this.

final burger = Burger(true, false, true, 2, false, true, 'large', null, true, false);
What is that fourth argument? Which boolean is the cheese? Swap any two of those booleans and it still compiles — you've shipped a bug that the type system can't see. This is the telescoping constructor, and it rots in two directions at once: it's unreadable at the call site, and it grows a new parameter every time the product gains an option.

One giant call, or one step at a timeTelescoping constructorBurger(true, false, true, 2,false, true, 'large',null, true, false,// which flag was which?);Swap two booleans and it still compiles.Every new option grows the call.Builder, step by stepb.addPatty()b.addCheese()b.size('large')b.build()Each step is named. Order is yours to set.

Dart softens the worst of this with named parameters — Burger(patty: true, cheese: false, ...) reads far better than ten bare booleans. That alone solves a lot of cases, and you should reach for it first. But it doesn't help when the object has to be assembled in stages: when you build a piece, run some logic, build another piece, and only at the end produce the finished thing. For that, you separate the assembling from the result.

2

The pattern — split the assembly from the finished object

Here's the move. Instead of one constructor that takes everything at once, you make a second object whose only job is to collect the parts. You poke at it step by step. When you're done, you ask it for the finished thing.

There are two roles, and they're deliberately separate.

The builder is mutable scratch space. It holds half-finished state and exposes one method per decision.

The product is the real object — often immutable — that the builder hands back from build().

class Burger {
  final bool patty;
  final bool cheese;
  final String size;
  Burger(this.patty, this.cheese, this.size);
}

class BurgerBuilder {
  bool _patty = false;
  bool _cheese = false;
  String _size = 'regular';

  void addPatty() => _patty = true;
  void addCheese() => _cheese = true;
  void setSize(String s) => _size = s;

  Burger build() => Burger(_patty, _cheese, _size);
}
The call site now reads like a list of decisions, not a row of mystery arguments.

final b = BurgerBuilder()
  ..addPatty()
  ..addCheese()
  ..setSize('large');

final burger = b.build();
Look at what that bought you. The booleans have names again. The order is whatever you want. And the builder can hold logic between steps — validate, branch, loop — which a single constructor call never could. The trade: you carry an extra object around. For a two-field class that's overkill; named parameters win. For an object you genuinely assemble in pieces, the builder earns its keep.

3

Flutter's builders are functions that build on demand

Now, the classic builder above is a whole class. Flutter rarely makes you write that. Instead it hands the pattern to you as a callback — a function named builder that produces a widget when asked.

You've already met these. ListView.builder takes an itemBuilder. showDialog takes a builder. There's even a widget literally called Builder. The shape is always the same: you hand in a function, and Flutter calls it later, when it's ready, to produce the widget.

ListView.builder(
  itemCount: messages.length,
  itemBuilder: (context, index) {
    return ListTile(title: Text(messages[index]));
  },
);
(context, index) => Widget, once per visible rowBuildContextindex: 42itemBuilderthe build strategyreturnsWidgetFlutter calls it on demand, only for rows in or near the viewport:itemBuilder: (context, i) => ListTile(title: Text(items[i]))Scroll down, row 43 appears, Flutter calls itemBuilder(context, 43).

The type of that callback is IndexedWidgetBuilder — which is just an alias for Widget Function(BuildContext, int). Flutter passes you the BuildContext and the row index; you return one widget. You're not building the whole list. You're handing the framework a recipe for building one item, and it runs that recipe whenever it needs another item.

The bare Builder widget exists for a narrower reason: it gives you a fresh BuildContext located inside the current widget. That matters when something like Scaffold.of(context) needs a context that sits below the Scaffold, not the one above it.

Scaffold(
  body: Builder(
    builder: (context) {
      // this context is inside the Scaffold
      return ElevatedButton(
        onPressed: () => Scaffold.of(context).openDrawer(),
        child: const Text('Open'),
      );
    },
  ),
);

4

Why the callback matters — deferred, lazy building

So why does Flutter bother with a callback at all? Why not just hand it a list of widgets? Because a function lets the framework defer the work. It calls your builder only when it actually needs the result — and for a long scrolling list, that's the whole game.

Compare the two ways to make a list. The plain constructor takes a finished children list. Every widget in it is built now, before a single pixel hits the screen.

// Eager: all 10,000 ListTiles built up front
ListView(
  children: [
    for (var i = 0; i < 10000; i++)
      ListTile(title: Text('Item \$i')),
  ],
);
The builder version takes a function and an itemCount. Flutter calls the function only for the rows near the viewport. Scroll, and it builds the next few; the ones far off-screen never get built until they're needed.

// Lazy: only the visible ListTiles are built
ListView.builder(
  itemCount: 10000,
  itemBuilder: (context, i) => ListTile(title: Text('Item \$i')),
);
Build all 10,000 rows, or only the ones you seeListView(children: [...])Row 0 (built)Row 1 (built)Row 2 (built)Row 3 (built)Row 4 (built)All rows built up front, even off-screen.ListView.builder(itemBuilder)viewportRow 41 (built)Row 42 (built)Row 43 — not builtRow 44 — not built... 9,955 more, unbuiltitemBuilder runs only as rows scroll in.Off-screen rows cost nothing yet.

For five items it makes no difference. For ten thousand it's the difference between a smooth scroll and a frozen frame. Rule of thumb: a short, fixed list you can write out by hand → ListView(children: [...]). A long or dynamic list → ListView.builder, every time. The callback isn't ceremony; it's what lets the framework skip the work you can't yet see.

5

Cascades and StringBuffer — building without a builder class

You saw the .. in the burger example and the ListView calls. That's the cascade operator, and it's Dart's built-in answer to fluent, step-by-step building. It lets you run a sequence of operations on one object without naming that object over and over.

The trick is what .. evaluates to. A normal . returns whatever the method returns. A .. runs the method but throws away its result and returns the receiver instead. So every step in the chain hands the same object to the next.

final paint = Paint()
  ..color = Colors.blue
  ..strokeWidth = 4.0
  ..style = PaintingStyle.stroke;
// paint is the Paint, not the result of the last assignment
The classic standard-library builder is StringBuffer. Concatenating strings with + in a loop allocates a fresh string every time — strings are immutable, remember. A StringBuffer is mutable scratch space: you write into it as many times as you like, then call toString() once to produce the finished, immutable string.

final sb = StringBuffer()
  ..write('Hi ')
  ..write('there')
  ..write('!');

final greeting = sb.toString();   // 'Hi there!'
Each .. returns the same buffer, then buildStringBuffer()the receiver..write('Hi ')buffer: "Hi "..write('there')buffer: "Hi there".toString()"Hi there"The whole chain, one expression:final s = (StringBuffer()..write('Hi ')..write('there')).toString();.. operates and discards its result; the object flows on.

This is the builder pattern with the boilerplate removed. StringBuffer is the mutable builder, write is a build step, and toString() is build(). Because Dart gives you the cascade for free, you rarely need to hand-write a builder class — you reach for a buffer or a cascade and get the same shape with less code.

6

copyWith — a builder for immutable objects

There's one more flavour, and it's the one you'll use most in real Flutter code. Sometimes you don't want a mutable builder at all. You have an immutable object and you want a slightly different immutable object — change one field, keep the rest. That's what copyWith is for.

The problem it solves: with a final field, you can't just reassign it. And rebuilding the whole object by hand means restating every field you didn't change, which is noise and a bug waiting to happen the moment you add a field.

class Settings {
  final bool darkMode;
  final String language;
  final double fontSize;

  const Settings({
    required this.darkMode,
    required this.language,
    required this.fontSize,
  });

  Settings copyWith({
    bool? darkMode,
    String? language,
    double? fontSize,
  }) {
    return Settings(
      darkMode: darkMode ?? this.darkMode,
      language: language ?? this.language,
      fontSize: fontSize ?? this.fontSize,
    );
  }
}
Each parameter is nullable, and ?? this.field means "use what you were given, otherwise keep the old value." So you name only what changes.

const current = Settings(
  darkMode: false, language: 'en', fontSize: 14.0,
);

final updated = current.copyWith(darkMode: true);
// darkMode flips to true; language and fontSize are untouched
This is a builder that produces in one step, with no mutable scratch object in between. It's the heart of immutable state management — Bloc, Riverpod, plain setState with immutable models all lean on it. You never mutate the old state; you build a new state that differs in exactly the fields you touched. One caveat: the ?? this.field trick can't distinguish "leave it alone" from "set it to null." When a field is itself nullable and you genuinely need to null it, plain copyWith can't express that — you'll see code generators like freezed step in to handle it.

7

When to reach for a builder, and the thread back to widgets

So when does a builder earn its place over a plain constructor? Walk down this list and stop at the first one that fits.
• Two or three fields, all known at once → a plain constructor, named parameters. No builder.
• Many optional fields, still all known at once → still a constructor with named parameters. Dart makes this clean enough that you don't need more.
• The object is assembled in stages, with logic between steps → a cascade, or a real builder class.
• You're producing widgets on demand, lazily → a builder callback like itemBuilder.
• You have an immutable object and want a tweaked copy → copyWith.

Notice these aren't five patterns. They're one idea — separate the act of constructing from the finished result — wearing five outfits sized to how much structure the job has earned. Most of the time Dart's cascade and named parameters cover you, and you never write the word "builder" at all.

Builder versus Strategy. We met Strategy last time: it's about which behaviour runs. Builder is about how an object gets assembled. They sit close together in Flutter — an itemBuilder is a builder callback, but it's also a strategy for how each row is constructed. The same callback can wear both labels, and that's fine; the labels are there to help you reason, not to box the code in.

And here's the thread back to everything Flutter. A widget tree is an object assembled from parts — you nest a Padding around a Column around your tiles, building the bigger thing out of smaller ones. The build method on every widget is a builder: it takes a context and returns the assembled subtree. You've been writing builders since your very first build(BuildContext context). Naming the pattern just tells you what your hands already knew.

Test your understanding

7 questions

Seven questions on the builder pattern — the telescoping-constructor problem, Flutter's builder callbacks, lazy construction, cascades, and copyWith for immutable state.

Search

Loading search...