Sparround

Rebuild performance and measurement

The performance impact of state management comes down to one question: how many widgets does a single change wake? The official performance guidance is concrete here:

  • Use `const` constructors on widgets as much as possible, since they allow Flutter to short-circuit most of the rebuild work. The flutter_lints package reminds you automatically.
  • Avoid repetitive and costly work in `build()` methods since `build()` can be invoked frequently when ancestor widgets rebuild.
  • Avoid overly large single widgets with a large `build()` function; split them based on encapsulation but also on how they change. The docs add that when setState() is called, all descendant widgets rebuild, so the call should be localised to the part that actually needs to change.
  • Prefer a `StatelessWidget` over a function for reusable pieces of UI.
  • When building a large grid or list, use the lazy builder methods — only the visible portion is built.
LibraryTool for narrowing the rebuild scope
Flutter (no library)Pushing `setState` down, `ValueListenableBuilder`, `const`, `ListenableBuilder`
Provider`Consumer` (with its `child` parameter), `Selector`, `context.select`
RiverpodThe `Consumer` widget, `provider.select(...)`, separate small providers
BLoC`BlocSelector`, `buildWhen`, placing `BlocBuilder` deeper

Measurement: don't optimise by guessing. DevTools' Performance view offers concrete options for finding rebuilds:

  • Track Widget Builds — shows build() method events in the timeline; the widget's name appears in the event.
  • Enhance tracing — options for more detailed tracing; the docs caution that frame times might be negatively affected while they are enabled.
  • Track Layouts and Track Paints — layout and paint events of render objects.

A strong way to phrase the sequence in an interview: measure first, then narrow the rebuild scope, then measure again. An optimisation claim without a number is a guess.

dart
// ❌ Bütün ekran hər dəyişiklikdə qurulur:
// - watch build-in yuxarısındadır
// - ağır widget-lər eyni metodun içindədir
// - const yoxdur
class DashboardBad extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final model = context.watch<DashboardModel>();
    return Column(
      children: [
        HeavyChart(data: model.chartData),
        ExpensiveMap(),
        Text('Bildiriş: ${model.unreadCount}'),
      ],
    );
  }
}

// ✅ Yalnız dəyişən hissə qurulur:
// - ağır widget-lər const və ya kənarda
// - abunəlik yalnız lazım olan sahəyə
class DashboardGood extends StatelessWidget {
  const DashboardGood({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Yalnız chartData dəyişdikdə qurulur.
        Selector<DashboardModel, List<Point>>(
          selector: (_, m) => m.chartData,
          builder: (_, data, __) => HeavyChart(data: data),
        ),
        const ExpensiveMap(), // heç vaxt rebuild olunmur
        Selector<DashboardModel, int>(
          selector: (_, m) => m.unreadCount,
          builder: (_, count, __) => Text('Bildiriş: $count'),
        ),
      ],
    );
  }
}

The same screen, two rebuild profiles.

The most common performance mistake is not in the state management but in subscribing too high: watching the whole model on the first line of build binds every part of the screen to a single change. It is the same mistake in all three libraries, and the same fix — push the subscription down the tree (Consumer, Selector, BlocSelector, small Consumer widgets in Riverpod).

📚 Sources and documentation