Unwanted widget rebuilds in Flutter waste CPU/GPU and cause jank. They happen when large parts of the tree update even though only a small value changed. The goal is to reduce rebuild scope and make builds cheap and side-effect-free.
Approaches
Approach A: Move work out of build
Keep build pure. Do expensive work once in lifecycle methods, not every frame.
import 'package:flutter/material.dart';
class ProfileCard extends StatefulWidget {
const ProfileCard({super.key, required this.name});
final String name;
@override
State<ProfileCard> createState() => _ProfileCardState();
}
class _ProfileCardState extends State<ProfileCard> {
late final String greeting; // computed once
@override
void initState() {
super.initState();
greeting = 'Hello, ${widget.name}'; // ✅ not recomputed in build
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(greeting),
),
);
}
}
Use:
initStateordidUpdateWidgetfor one-time or rare computations.late finalfor memoized results.- Avoid async calls or
setStateinsidebuild.
Approach B: Split widgets to isolate rebuilds
Lift static parts into separate widgets so they never rebuild.
class PageShell extends StatelessWidget {
const PageShell({super.key, required this.counter});
final int counter;
@override
Widget build(BuildContext context) {
return Row(
children: const [
_Sidebar(), // ✅ never rebuilds
VerticalDivider(),
],
);
}
}
class _Sidebar extends StatelessWidget {
const _Sidebar();
@override
Widget build(BuildContext context) {
return SizedBox(width: 220, child: Text('Static menu'));
}
}
Rule of thumb: small widgets, shallow rebuilds.
A small rebuild boundary beats clever caching.
Approach C: Use const aggressively
const widgets are canonicalized and skipped during diffing.
class CountView extends StatelessWidget {
const CountView({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.timer),
const SizedBox(width: 8),
Text('$count'),
],
);
}
}
Make constructors const, mark child trees const, and prefer const literals everywhere.
Approach D: Limit rebuild scope with selectors or buildWhen
Only rebuild the widgets that depend on the changed slice of state.
Provider (Selector):
class Cart extends ChangeNotifier {
int _total = 0;
int get total => _total;
void add(int price) {
_total += price;
notifyListeners();
}
}
class CartTotalChip extends StatelessWidget {
const CartTotalChip({super.key});
@override
Widget build(BuildContext context) {
return Selector<Cart, int>(
selector: (_, cart) => cart.total,
builder: (_, total, __) => Chip(label: Text('Total: \$${total}')),
);
}
}
Bloc (buildWhen):
BlocBuilder<CartCubit, CartState>(
buildWhen: (prev, next) => prev.total != next.total,
builder: (_, state) => Text('Total: ${state.total}'),
);
Riverpod (select):
final cartProvider = StateNotifierProvider<CartNotifier, Cart>((ref) => ...);
class CartTotalText extends ConsumerWidget {
const CartTotalText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final total = ref.watch(cartProvider.select((c) => c.total));
return Text('Total: $total');
}
}
Approach E: Use child parameters to freeze subtrees
Some builders let you pass a static child that won’t rebuild.
class LikeButton extends StatelessWidget {
const LikeButton({super.key, required this.count});
final int count;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text('$count', key: ValueKey(count)),
);
}
}
class CardWithStaticHeader extends StatelessWidget {
const CardWithStaticHeader({super.key, required this.builder});
final Widget Function(BuildContext) builder;
@override
Widget build(BuildContext context) {
return Column(
children: [
const _Header(), // ✅ static
builder(context),
],
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) => const Text('My Header');
}
Also available in Consumer, BlocBuilder, and LayoutBuilder.
Approach F: Lists — prefer builder constructors and keys
Avoid rebuilding long lists; let Flutter lazily build visible items.
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return _ItemTile(
key: ValueKey(items[index].id),
item: items[index],
);
},
);
- Use
ListView.builderorGridView.builder. - Provide stable
Keys. - Avoid
ListView(children: [...])for large lists.
Approach G: Implement shouldRepaint / shouldRebuild
Stop unnecessary redraws and rebuilds by comparing inputs.
class RingPainter extends CustomPainter {
RingPainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {/*...*/}
@override
bool shouldRepaint(covariant RingPainter old) => old.color != color;
}
For sliver delegates:
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Text('$index'),
addAutomaticKeepAlives: false,
),
);
Approach H: Use RepaintBoundary for paint isolation
RepaintBoundary reduces paint cost of complex areas.
It doesn’t stop rebuilds but improves rendering performance.
const RepaintBoundary(
child: ComplexChart(),
);
Decision Guide
- Big widget rebuilding on tiny change? Split widgets and add
const. - State management causing full-tree rebuilds? Use selectors or
buildWhen. - List feels slow? Use
ListView.builderand keys. - Heavy computation in
build? Move toinitStateor memoize. - Custom graphics? Implement
shouldRepaintor addRepaintBoundary.
Trade-offs:
- More widgets/files = clearer structure but more boilerplate.
- Selectors = precision control, but extra code.
- Keys = fix identity, but must remain stable.
Recommendation
- Keep
buildpure — no logic or async calls. - Split widgets and use
consteverywhere. - Use selectors (
Selector,select,buildWhen) to target updates. - Profile with the Flutter Performance Overlay and DevTools to verify gains.
Leave a Reply