Eliminate useless rebuilds thanks to const, keys and reconciliation, then measure with DevTools.
Open this lesson in KodokonFlutter manipulates three trees: the widgets (immutable, disposable configurations), the Elements (living instances that carry state) and the render objects (layout, painting). Rebuilding a widget is cheap; what costs is layout and cascading paint. During a rebuild, Element.updateChild applies three rules in order: if the new widget is identical (same instance) to the old one, the entire subtree is short-circuited; if Widget.canUpdate is true (same runtimeType, same key), the Element is kept and simply reconfigured; otherwise, the Element is destroyed then re-inflated, state included. The const keyword exploits the first rule: a constant expression is canonicalized by the compiler, the same instance comes back on every build, identical is true - and the whole branch is skipped.
import 'package:flutter/material.dart';
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
@override
State<CounterScreen> createState() =>
_CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const _Header(),
Text('Total: $_count'),
FilledButton(
onPressed: () => setState(() => _count++),
child: const Text('Increment'),
),
],
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
debugPrint('build _Header');
return const Text('Counter');
}
}For lists of children, reconciliation pairs first by position. Reorder stateful items without keys and each Element keeps the state of the previous occupant of its position: checkboxes ticked in the wrong place, text fields swapped. A Key changes the pairing rule: the algorithm finds the Element carrying the same key, even when moved within the list. Use ValueKey on a stable business identifier. GlobalKey goes further - it lets you reparent an entire subtree without losing its state - but it has a real cost (global registry, uniqueness constraint across the whole tree): reserve it for genuine needs like Form or reparenting.
import 'package:flutter/material.dart';
class TodoList extends StatelessWidget {
const TodoList({super.key, required this.items});
final List<String> items;
@override
Widget build(BuildContext context) {
return ListView(
children: [
for (final item in items)
Dismissible(
key: ValueKey(item),
onDismissed: (direction) {},
child: ListTile(title: Text(item)),
),
],
);
}
}Measure before optimizing. In DevTools, the Performance view traces per-frame time for the two critical threads: UI (your Dart code: build, layout) and raster (rasterizing the layers). The budget is about 16 ms at 60 Hz. Jank on the raster side isn't fixed by optimizing your builds: it comes from the cost of painting (shadows, saveLayer, oversized images). Enable Track widget builds to see who's rebuilding, and debugRepaintRainbowEnabled to visualize repainted zones - the border changes color on every repaint. RepaintBoundary isolates a subtree into its own layer: a spinner repainting continuously no longer dirties its neighbors.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
debugRepaintRainbowEnabled = true;
runApp(const DemoApp());
}
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
showPerformanceOverlay: true,
home: Scaffold(
body: Center(
child: RepaintBoundary(
child: CircularProgressIndicator(),
),
),
),
);
}
}