← Articles

Avoiding unnecessary widget rebuilds in Flutter

By Mark · 4 February 20251 view

Every widget rebuild has a cost. In a well-built Flutter app, only widgets that depend on changed data rebuild. In a poorly built app, entire subtrees rebuild when a single value changes. This guide covers the practical techniques for eliminating unnecessary rebuilds.

Why unnecessary rebuilds happen

Flutter calls build() on a widget when:

  1. The widget's state changes
  2. Its parent rebuilds
  3. An InheritedWidget it depends on changes

Case 2 is the most common source of unnecessary rebuilds. If a parent rebuilds (because its state changed), all children rebuild too — even if their inputs didn't change.

Use const constructors

The cheapest rebuild is no rebuild. const widgets are created at compile time and never rebuilt:

// This widget is never rebuilt
const Text('Hello, World!')

// This rebuilds every time the parent does
Text('Hello, World!') // Not const!

Enable the lint:

# analysis_options.yaml
linter:
  rules:
    prefer_const_constructors: true
    prefer_const_widgets: true

Extract widgets instead of methods

// BAD: _buildHeader() is called every time _HomeScreenState rebuilds
class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        _buildHeader(),  // Always rebuilds with parent
        _buildBody(),
      ],
    );
  }

  Widget _buildHeader() => const Header();
}

// GOOD: HeaderWidget is a separate widget; Flutter can decide not to rebuild it
class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const Column(
      children: [
        HeaderWidget(),  // Flutter checks if it needs to rebuild
        BodyWidget(),
      ],
    );
  }
}

Isolate state to the smallest widget

// BAD: entire screen rebuilds when counter changes
class _CounterScreenState extends State<CounterScreen> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')), // Rebuilds unnecessarily
      body: const ExpensiveList(),                   // Rebuilds unnecessarily
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => _count++),
        child: Text('$_count'),  // Only this needs to rebuild
      ),
    );
  }
}

// GOOD: only the counter text rebuilds
class _CounterButtonState extends State<CounterButton> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => setState(() => _count++),
      child: Text('$_count'),
    );
  }
}

BLoC: use buildWhen

BlocBuilder<OrdersBloc, OrdersState>(
  // Only rebuild when the order count changes
  buildWhen: (previous, current) =>
      previous.orders.length != current.orders.length,
  builder: (context, state) => OrderCountBadge(count: state.orders.length),
)

Riverpod: select for fine-grained subscriptions

// Only rebuild when the name field changes
final name = ref.watch(userProvider.select((u) => u.name));

// vs. this, which rebuilds when ANY field in userProvider changes:
final user = ref.watch(userProvider);

RepaintBoundary

RepaintBoundary tells Flutter that this subtree should be painted separately. Animations inside it don't repaint the rest of the screen:

RepaintBoundary(
  child: AnimatedWidget(), // Painting is isolated from the parent tree
)

Use it around:

  • Animations in the middle of static content
  • Heavy custom painters that update frequently
  • Video/maps widgets

ListView.builder vs ListView with children

// BAD: builds all items upfront even if only 5 are visible
ListView(
  children: orders.map((o) => OrderCard(order: o)).toList(),
)

// GOOD: only builds visible items
ListView.builder(
  itemCount: orders.length,
  itemBuilder: (_, i) => OrderCard(order: orders[i]),
)

ValueNotifier for simple local state

For local state that doesn't need BLoC or Riverpod:

class _ExpandableCardState extends State<ExpandableCard> {
  final _isExpanded = ValueNotifier<bool>(false);

  @override
  void dispose() {
    _isExpanded.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () => _isExpanded.value = !_isExpanded.value,
          child: const CardHeader(),
        ),
        // Only this rebuilds when _isExpanded changes
        ValueListenableBuilder<bool>(
          valueListenable: _isExpanded,
          builder: (_, isExpanded, child) {
            return AnimatedSwitcher(
              duration: const Duration(milliseconds: 200),
              child: isExpanded ? const CardBody() : const SizedBox.shrink(),
            );
          },
        ),
      ],
    );
  }
}

Verify with DevTools

Enable widget rebuild tracking in Flutter DevTools:

  1. Open DevTools → Widget Inspector
  2. Enable Track Widget Builds
  3. Interact with the app
  4. Rebuilt widgets highlight in the tree

Widgets that highlight on every scroll or animation frame are candidates for optimization.

Common pitfalls

Creating new objects in build(). build() can be called many times. Creating a TextStyle(...) or BoxDecoration(...) inside build creates a new object every rebuild, causing child widgets to see a "changed" property and rebuild:

// BAD: creates a new object every build
container.decoration = BoxDecoration(color: Colors.blue);

// GOOD: define outside build or use const
static const _decoration = BoxDecoration(color: Colors.blue);

Anonymous functions in build(). () => doSomething() creates a new function object every build. This causes widgets that receive callbacks to see a changed prop. Extract callbacks to named methods:

// BAD: new closure every build
button.onTap = () => context.read<OrdersBloc>().add(LoadOrders());

// GOOD
void _onLoadTap() => context.read<OrdersBloc>().add(LoadOrders());
button.onTap = _onLoadTap;

Sign in to like, dislike, or report.

Comments

No comments yet. Be the first!

Sign in to leave a comment.