Skip to main content

Command Palette

Search for a command to run...

The Hidden Cost of Rebuilds in Flutter

Updated
6 min readView as Markdown
The Hidden Cost of Rebuilds in Flutter
G
Hello, I am a senior Flutter developer with vast experience in crafting mobile applications. I am a seasoned community organizer with vast experience in launching and building Google Developer communities under GDG Bugiri Uganda and Flutter Kampala.

Imagine you're in your house and you turn on the light in your bedroom — but somehow every single light in the house comes on: the kitchen, the bathroom, even the one outside that nobody uses. Nothing is technically broken. Everything still works. But your electricity bill is quietly crying in the background.

That's exactly what happens in a Flutter app when rebuilds aren't controlled. One small state change ends up doing far more work than it needs to, and the app doesn't crash or throw an error to tell you. It just gets a little heavier, a little less smooth, one screen at a time.

"Rebuilds are cheap" is true, and also the wrong thing to optimize for

You've probably heard this line, and it's correct: Flutter is built to make rebuilding a widget fast. The framework diffs the new widget configuration against the old one and only touches the render objects that actually changed. A single rebuild, in isolation, costs almost nothing.

The part that gets left out is the word unnecessary. Rebuilds are cheap — until you're doing thousands of them for state that thirty widgets on screen don't actually care about. The cost isn't in any one rebuild; it's in the blast radius.

Seeing the blast radius, not just describing it

Here's a screen with a counter and a list of ten static "activity" items sitting below it:

class DashboardScreen extends StatefulWidget {
  const DashboardScreen({super.key});
  @override
  State<DashboardScreen> createState() => _DashboardScreenState();
}

class _DashboardScreenState extends State<DashboardScreen> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    debugPrint('DashboardScreen build');
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: Column(
        children: [
          Text('Count: $count'),
          ElevatedButton(
            onPressed: () => setState(() => count++),
            child: const Text('Increment'),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: 10,
              itemBuilder: (context, i) {
                debugPrint('ActivityTile $i build');
                return ActivityTile(index: i);
              },
            ),
          ),
        ],
      ),
    );
  }
}

Tap the button once, and the console prints DashboardScreen build followed by all ten ActivityTile builds — every time, even though none of those tiles depend on count in any way. The setState call lives in _DashboardScreenState, which sits above the entire Column, so every rebuild of that state re-runs build() for everything below it. Flutter isn't being wasteful here; it's doing exactly what you told it to do. The waste is in where the state was declared, not in how Flutter handles it.

Now move the counter's state down into its own small widget, so the parent no longer owns it:

class DashboardScreen extends StatelessWidget {
  const DashboardScreen({super.key});

  @override
  Widget build(BuildContext context) {
    debugPrint('DashboardScreen build'); // now prints exactly once, ever
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: Column(
        children: [
          const CounterWidget(), // owns its own state
          Expanded(
            child: ListView.builder(
              itemCount: 10,
              itemBuilder: (context, i) => ActivityTile(index: i),
            ),
          ),
        ],
      ),
    );
  }
}

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    debugPrint('CounterWidget build');
    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () => setState(() => count++),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

Tap the button now, and the console prints only CounterWidget build. DashboardScreen never re-runs, and the ten ActivityTile widgets never re-run either — not because Flutter got smarter, but because the dirty element is now three levels lower and nothing above or beside it depends on that state. Same feature, same UI, one line of debug output instead of eleven. That's the entire fix: the size of a rebuild is decided by where you put the setState, not by how big your widget tree is.

const is the fix most lists like this skip

Marking the two static children constconst CounterWidget(), and the same on any widget you know won't change — tells Flutter that the widget instance is identical to the last one, so it skips rebuilding that subtree entirely rather than rebuilding it and finding no changes. It costs nothing to write and is very often the single highest-leverage change you can make in an existing screen, because it requires no restructuring — you're not moving state anywhere, just telling Flutter about the fact that a given branch is already static.

The other three tools, actually used

Naming ValueListenableBuilder, Selector, and select without showing them is not much more useful than not naming them at all, so here's each one doing the specific job it exists for.

ValueListenableBuilder scopes a rebuild to exactly the widget that displays a value, with no external package:

final ValueNotifier<int> countNotifier = ValueNotifier(0);

ValueListenableBuilder<int>(
  valueListenable: countNotifier,
  builder: (context, value, child) => Text('Count: $value'),
)

Only the Text inside the builder rebuilds when countNotifier.value changes — the parent widget never re-runs.

Selector (from the provider package) does the same thing for a value pulled out of a larger model, so a widget doesn't rebuild every time any field on that model changes — only when the specific field it selected does:

Selector<CartModel, int>(
  selector: (context, cart) => cart.itemCount,
  builder: (context, itemCount, child) => Text('$itemCount items'),
)

Even if CartModel also tracks a total price, shipping address, and discount code, this widget only rebuilds when itemCount specifically changes.

Riverpod's select applies the identical idea to a Riverpod provider:

final itemCount = ref.watch(cartProvider.select((cart) => cart.itemCount));

All three tools solve the same problem from three different ecosystems: instead of watching a whole object and rebuilding on any change to it, you watch the one field you actually render, and Flutter's diffing only fires when that field moves.

Why this compounds instead of staying flat

In a five-screen app, a state object sitting one level too high costs you a handful of unnecessary widget rebuilds per tap — cheap enough that you'll never notice. In a fifty-screen app with shared state at the top of the tree, that same pattern means a single update anywhere can walk through dozens of widgets that have nothing to do with the change, and it happens on every interaction, not just one. The mechanism doesn't change as the app grows. Only the blast radius does, which is exactly why this class of problem tends to surface as "the app feels heavier than it used to" months into a project rather than as a bug report on day one — nothing is failing, so nothing points you at the cause.

The takeaway

Don't try to eliminate rebuilds — you can't, and you shouldn't want to; they're how Flutter updates the screen at all. Control where they start and how far they spread: keep state as close as possible to the widgets that actually use it, mark static subtrees const, and reach for ValueListenableBuilder, Selector, or select when a widget only cares about one slice of a larger piece of state. A setState call three widgets deep and a setState call at the root of your screen are the same amount of code and completely different amounts of work — the only thing that changed is where you put it.