Avoiding unnecessary widget rebuilds in Flutter
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:
- The widget's state changes
- Its parent rebuilds
- An
InheritedWidgetit 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:
- Open DevTools → Widget Inspector
- Enable Track Widget Builds
- Interact with the app
- 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.