Blog - Flutter Bounty Hunters

The builder pattern is a terrible idea for your widget tree

June 12, 2023
Matt Carroll Chief

With few exceptions, Flutter widget trees have always been declarative. You “declare” the structure of the tree, instead of “generating” the tree. Your code is the UI structure, and the UI structure is your code. This direct connection has been hugely beneficial to help developers learn how to use Flutter, and to quickly read and understand other developers’ code. Unfortunately, Swift UI has popularized the idea of infecting declarative widget trees with imperative builder patterns for widget composition. The Swift UI fans in the Flutter community are now pushing that approach for Flutter. It’s a terrible idea, and you should understand the cost of this behavior before you get sucked into the social media echo chamber.

What is a builder pattern and how does it apply to widget trees?

The builder pattern is nothing new. It’s been a part of programming design patterns for decades. The builder pattern is an approach whereby a configuration is built up across some number of sequential methods.

Here’s an example of a builder pattern that’s used to configure a document editor within a Super Editor test suite:

final testContext = await tester
    .createDocument() 
    .withSingleEmptyParagraph()
    .withEditorSize(const Size(300, 300))
    .pump();

In this example code, our builder configures a document with a single, empty paragraph, within an overall editor size of 300px by 300px, and then pumps the widget tree so the test can work with the editor.

There’s nothing inherently wrong with the builder pattern. As you can see, I make ample use of the builder pattern. But only when the builder pattern solves a real problem. In this case we’re saving multiple assignments, and a number of re-declarations. Moreover, the people who read and write this code don’t care, at all, about what those assignments might look like. The objects that we’ve hidden don’t hinder any typical interaction with this code. When multiplied across thousands of tests, we’re saving a dramatic amount of code, and making it even easier to understand the intention of the code.

Now let’s look at an example of a builder pattern within an otherwise declarative widget tree.

Widget build(BuildContext context) {
  return const Text(\"Hello, world\")
    .padding([Edge.leading, Edge.vertical], 20)
    .padding([Edge.trailing], 8)
}

This is an example of a bad use of the builder pattern. I’ll explain why this is a bad use in the rest of this article.

You’re not solving a real problem

First, it’s important to recognize that this hot new use of builders in widget trees isn’t solving anything. There’s no problem here to begin with. To illustrate this point, let’s look at equivalent widget trees, back-to-back.

Widget build(BuildContext context) {
  return const Text(\"Hello, world\")
    .padding([Edge.leading, Edge.vertical], 20)
    .padding([Edge.trailing], 8)
}

Widget build(BuildContext context) {
  return Padding(
    padding: const EdgeInsets.only(right: 8),
    child: Padding(
      padding: const EdgeInsets.only(left: 20, top: 20, bottom: 20),
      child: Text(\"Hello, world\"),
    )
  );
}

No doubt, some readers are already red in the face by hearing me say that there’s no problem being solved with widget tree builders. They’ll point to the fact that the builder version only has three lines and the declarative version has seven. They’ll tell you that the builder version is “cleaner”. They’ll tell you that Swift UI let’s developers use builders in the tree, so Flutter should, too. Etc, etc, etc.

But none of these responses identify any real problem.

The fact that the traditional declarative tree uses a few more lines, two of which are just parentheses, isn’t a problem. You’re welcome to hold the preference that it’s super important to minimize line count everywhere, but that’s just your opinion. It’s not an aspect of Flutter that’s holding anyone back.

You might also personally feel that the builder approach is “cleaner”. I personally disagree. But regardless, we’re back to stylistic preferences, not objective problems. Your sense of “cleanliness” doesn’t add any new features, or fix any outstanding bugs.

You might be a Swift UI fanboy, fixating on every detail of Swift UI that isn’t available, or isn’t popular with Flutter devs. But, “SwiftUI did it” isn’t an argument that’s aimed at a problem. It’s just a copy-cat FOMO mentality. If you truly love Swift UI so much that you feel the need to extend it’s stylistic practices to Flutter, then with all do respect, maybe Swift UI is the better choice for you. Flutter needs to focus on demonstrable problems, bugs, and shortcomings.

There simply isn’t any meaningful problem that this approach alleviates. We’re creating the same final result, based on the same motivation, but with a different mechanism for getting there. A worse mechanism, as I’ll describe later.

You’re creating new problems

I’ve outlined the ways in which the pro-builder devs are only focused on stylistic details instead of real problems. One might point out that it’s fine to embrace different styles. And that’s true. Unfortunately, these builders aren’t just a different style. They create new problems. And those problems will be exacerbated over time as more developers embrace this pattern.

You’re screwing with readability

Despite the fact that some developers consider the builder pattern in widget trees to be “cleaner”, the truth is that it creates an oscillating reading order that’s cumbersome and confusing. Reading large swathes of code with builders requires constant flip-flopping of the mental model.

Let’s look at a slightly expanded version of the earlier widget tree with builders.

Widget build(BuildContext context) {
  return Scaffold( // 1
    body: Container( // 2
      child: const Text(\"Hello, world\") // 5
        .padding([Edge.leading, Edge.vertical], 20) // 4
        .padding([Edge.trailing], 8) // 3
      ),
    ),
  );
}

Notice the numbers I added in comments to the right of each widget declaration and each builder method. Those are the relative descendant levels of each widget in this tree.

Do you notice anything about those numbers?

In a normal, declarative widget tree, you’re always reading from the outside, in. As you read down the tree, you read in a monotonically increasing depth order.

In an imperative builder segment, the reading order is reversed. You’re now reading from the inside, out. As you read down the imperative method calls, you’re reading a monotonically decreasing depth order.

By combining imperative builders with declarative widget compositions, you’re forcing the reader to perpetually jump between an outside-in mental model, and an inside-out mental model. Some developers might find this easy. But many other developers will find this difficult, tedious, and confusing. We’ve now arrived at an objective problem, created by this “cleaner” builder approach.

Even if this information about build order doesn’t convince you that this pattern creates a problem, then consider the simple fact that we’re at least doubling the number of ways that something is done. When we have an existing approach, which allows us to accomplish our goals, and then we add a second approach, which doesn’t extend our abilities, we’re increasing complexity and confusion without generating value. That, in itself, is also a problem.

You don’t know what you’re composing

I’ve pointed out that this builder approach isn’t declarative, it’s imperative. Some folks on Twitter disagree. Neither of us own the word declarative, so it depends largely on how you define it. But I draw an important distinction between the declarative nature of widget composition, and the imperative nature of these builders.

The important part about the declarative nature of a widget tree is that your code IS the result, and the result IS your code. There’s no magic that’s hidden from you. You write a tree of constructors, which directly yields a tree of widgets, and those widgets determine your UI. What’s the mapping from your widget constructors to the widget objects? It’s one-to-one. Every widget constructor that you declare will yield a widget of that exact type, configured with those exact properties. You aren’t “generating” the widget tree, you’re “declaring” it.

return SizedBox(
  child: DecoratedBox(
    child: Text(\"...\"),
  ),
);

// Produces a widget tree at runtime that looks like:
//  - SizedBox
//    - DecoratedBox
//      - Text

The builder pattern has no compiler-guaranteed direct relationship to the widget tree. The best you can hope for is that a developer chose a name for a builder method, which reflects the name of the top-level widget that’s produced by the builder function. Again, there’s absolutely no technical enforcement of this policy, it’s just what you hope for.

Let’s revisit the simple padding example:

Widget build(BuildContext context) {
  return Scaffold(
    body: Container
      child: const Text(\"Hello, world\")
        .padding([Edge.leading, Edge.vertical], 20)
        .padding([Edge.trailing], 8)
      ),
    ),
  );
}

Based on the code above, what’s the exact widget tree that you’re sending to the Flutter framework? It’s literally impossible to answer this question, without reading the source code of the padding() method. Of course, we hope that appropriate documentation makes this clear, but the compiler can’t read documentation. The important point here is that your traditional, declarative widget tree is the direct manifestation of what’s sent to the Flutter framework. But these builder methods create a separation between you and the actual widget tree. You don’t know what you’re building unless you read the source code of every builder method.

Some developers might argue that this problem isn’t new. Given that one widget can build an entire subtree of other widgets, Flutter developers are always working with hidden widgets. Why is this any different? I would concede that, at times, Flutter developers do need to jump into the source code of a widget to see the subtree that it builds. It’s also true that most of the time, we don’t need to. Our knowledge of the top-level widget that we compose is sufficient. But these builders obscure even the top-level widget. In fact, they don’t just obscure the widget that they return - these builders might return completely different widgets based on different conditions, which are invisible to you from outside the method. My point isn’t that widgets and builders are completely different, but that builders create additional obscurity beyond that of traditional widget composition.

Let’s look at what happens when the builder pattern is used for something a little more complex than replacing a simple Padding widget. The following is an example pulled directly from the flutter_animate package README:

Text(\"Hello World!\").animate()
  .fadeIn() // uses `Animate.defaultDuration`
  .scale() // inherits duration from fadeIn
  .move(delay: 300.ms, duration: 600.ms) // runs after the above w/new duration
  .blurXY() // inherits the delay & duration from move

What do each of these methods return? Is anything mutated across them? What’s the final output? What’s the widget tree structure that you’ve just sent to the Flutter framework?

We have absolutely no idea! We just hope that it works and that we never need to know what’s really happening.

Note, this is only a slightly more complicated example than the earlier padding example. And yet, with this little bit of added complexity, we’ve completely blown away any knowledge of the widget tree that we’re building. It’s gone. Without scouring the source code for every single method in these imperative chains, you simply don’t know what widget tree you’re generating.

Moreover, even if you start jumping into the source code for these imperative methods, you’re now responsible for becoming a human widget runtime. You have to remember what the earlier methods did, so that you can understand the impact of later methods. This mental complexity adds up FAST!

As far as I’m concerned, this is not a declarative approach to widget composition, and it’s many headaches waiting to happen. These builders promote the kind of black box mentality that we’ve seen in traditional Android and iOS development. Developers will blindly use these tools, but the moment anything goes wrong, those developers will be dependent upon the package author to understand what they did wrong. The developer using the tool can’t make heads or tails of what they’re actually doing with the tool. It either works perfectly, or it’s hopelessly broken. We do not want a culture of mass dependency on a small number of black boxes.

The counter points

I’ve pointed out that this style of UI programming isn’t solving a real problem, and it’s definitely creating problems, so there can’t be much of a counterargument. However, I’ll address some feedback from the echo chamber, anyway.

But it’s still declarative!

I’ve described this approach as imperative, but others have emphatically called it declarative.

First, I’ll extend an olive branch to those people. Yes, this builder pattern approach is still declarative UI programming. That’s true. You express the UI result that you want, and then somehow it’s delivered to you. That’s declarative UI programming.

However! This approach is not declarative widget composition. You’re no longer declaring the widgets you want, you’re generating them. This might sound like an irrelevant point. Who cares about declarative widget composition as long as you have declarative UI programming, right? Wrong.

The widget tree is THE handoff point between your code, and the Flutter framework. The whole widget tree is essentially one big order for a user interface, and Flutter fulfills that order. Flutter will do exactly what the widget tree tells it to do. Nothing more, nothing less. The point at which anything goes wrong with your Flutter UI, that issue ties back, in some way, to what you put in your widget tree.

Today, you can see all the widgets in your tree. You know exactly what you’re ordering. You might spend a few extra lines of code to retain that clarity, but you gain confidence that you know what you’re ordering from the Flutter framework. You’re also able to read that order in a consistent outside-in composition. No guessing or reorientation is required.

The builder pattern approach adds an entire layer around your widget tree. It’s impossible for you to be sure what you’re sending to Flutter, without reading the source code of every one of those methods. And I’ll repeat again, this approach isn’t going to be limited to the occasional padding. As with any social trend, if this becomes popular, you will see these obscuring imperative composition calls everywhere. They will be used for the most trivial of things. Because it’s popular.

To the extent possible, you want to be able to see the widget tree that you’re submitting to the Flutter framework. This direct connection between your non-imperative declarations, and the Flutter framework, is an immense value. Don’t take it lightly.

But we already do imperative stuff in widget tree declarations!

It’s been mentioned that Flutter developers already do imperative things in the widget tree, e.g., generative loops. That’s true.

Widget build(BuildContext context) {
  return Column(
    child: [
      for (int i = 0; i < 5; i += 1)
        Text(\"Hello, #$i\"),
    ],
  );
}

There’s definitely a balancing act between declarative and imperative widget composition. I’m not here to argue for some kind of purity. That’s why I gave you concrete descriptions of the problems associated with this builder pattern proposal in widget trees.

So why might I be OK with for-loops and if-statements inside the widget tree?

Loops and conditionals were added to widget trees to solve a very real problem. Developers were dedicating a lot of code at the top of their build method to generate widgets that they’d later insert within their primary tree.

Widget build(BuildContext context) {
  final children = <Widget>[];
  for (int i = 0; i < 5; i += 1) {
    children.add(Text(\"Hello, #$i\"));
  }

  return Column(
    child: children,
  );
}

Given the old way, and the new way, which of these two approaches makes it easier for you to see what’s actually going into the widget tree?

Somewhat ironically, placing imperative widget generation inside of the widget tree actually makes the widget tree more declarative than it was before. We achieve the same goal, with the same motivation, with even greater clarity about the widgets that we’re composing.

This is an easy call when we begin with a real problem. But, there is no similar problem being solved by placing builder patterns in widget trees.

The echo chambers are wasting breath on this

One of the problems with echo chambers is that the tiniest point of annoyance with an API can circulate so many times that a group of developers convince themselves that this tiny annoyance is actually some kind of existential problem. That’s what’s happening with this pattern.

On Twitter, I pointed out that builders in widget trees are a terrible idea, and I pray that Flutter developers don’t make this a common practice. The reaction was so strong that I didn’t simply get a reply, but a full-blown retweet about how I’m the reason that Flutter “is falling behind”…whatever that means.

Twitter Post 1

This response, of course, was in the middle of a few developers explaining to themselves why my opinion is harmful, or uneducated, and how I don’t know what I’m talking about.

Twitter Post 2

They’ve also convinced themselves that there’s some kind of God-like manifest destiny for all UI toolkits, and because there’s only one appropriate path, Flutter is falling behind.

Twitter Post 3

Unfortunately, this is what happens in echo chambers. A small set of narrow views reflect and refract until the people in that chamber can’t even imagine how someone like me might have an opposing view. If I do, it’s because “I don’t understand what declarative means”, and “I’m holding Flutter back”, and “this is the only way”.

Programmer culture is the worst thing about the software industry. So much potential is wasted, and so many problems are created, as a direct consequence of the poor ways in which we compare ideas. We also suffer perpetual FOMO that promotes, at best, a penny-wise and dollar-foolish approach to long-tail toolkit development. Many developers treat our foundational technologies like action figures to be played with, banged against each other, and quickly replaced with a newer toy. This is why we can’t have nice things.

In this case, a tiny API grievance has been exaggerated into a full-blown existential crisis! Declarative widget composition is holding Flutter back from the universal, singular path of UI toolkits. If you disagree, you don’t know what you’re talking about, and you might just kill Flutter!

I assume that most readers of this blog know at least a little something about my history with Flutter. For those that don’t, I was one of the earliest Flutter educators, teaching hundreds of thousands of developers how to compose complex user interfaces with Flutter, through YouTube videos. I worked on the Flutter team for two years, making it possible to integrate Flutter within existing Android apps. I speak at conferences and Meetups, evangelizing Flutter. I’ve spent the last three years building Flutter experiences for clients, training other developers, and building dozens of open source Flutter and Dart packages. I’ve turned open source Flutter and Dart development into a business model to help promote the health of the Flutter community. I’m currently in the process of bringing wide-ranging document support to Flutter and Dart.

If you think that I’m holding Flutter back, you might be in an echo chamber.

How to solve a real problem

Solving a real problem isn’t a complicated process. It’s boring and tedious…but it’s not complicated. Anyone who wants to solve Flutter problems can follow this recipe:

  1. Clearly describe a problem.
  2. Show a concise variety of examples of the problem.
  3. Describe the cost of the problem.
  4. Accumulate all reasonable solutions to the problem.
  5. Ensure that at least one solution has a better cost/benefit ratio than the existing problem. If not, continue living with the existing problem.
  6. Pick the least bad solution to the problem, and implement it.
  7. Measure the results. If the solution didn’t play out like you anticipated, roll it back.
  8. Publish the solution as stable.

If you’re serious about solving problems in Flutter, these are the steps you should go through. People agreeing with your preferences on Twitter is not a replacement for serious proposals.

Summary

The use of imperative builders within declarative widget trees is a growing trend. It’s spreading through the social media echo chambers. But it’s an awful idea.

The use of imperative builders within widget trees don’t extend the ability of Flutter, nor do they fix any meaningful Flutter problems.

The harm of imperative builders is significant, and likely. Builders create unpredictable depth orders, harming readability, and increasing confusion. Builder names have no compile-time relationship to the widgets they generate - this requires developers to read a lot more source code, and also to behave like a human widget runtime. Builders, as a widget tree pattern, won’t relegate themselves to the periphery of development. If they become popular, they will infect packages, within packages, within packages. The direct complexity of this pattern will be multiplied by orders of magnitude as that complexity moves deeper within our package ecosystem.

Flutter has many real shortcomings. There are plenty of places to dedicate our attention, and to help build a better future for Flutter. I’ve spoken about many of them. But real problems begin with real problem statements, and they include a clear analysis of all options. For builder patterns in widget trees, there is no clear problem statement, no alternatives have been proposed, and little to no analysis has been done on the costs of this approach.

The Flutter community should reject this pattern, and fight to retain the beauty of Flutter’s existing simplicity.