Flutter Widget Lifecycle Explained: From Build to Dispose
Every Flutter Bug Report Traces Back to the Lifecycle
Memory leaks, stale data, controllers that crash on hot reload — almost all of them come from misunderstanding when Flutter calls which lifecycle method. Here's the sequence that actually matters.
The core sequence
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State {
late final ScrollController _controller;
@override
void initState() {
super.initState();
_controller = ScrollController();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Called after initState, and again if an InheritedWidget above changes
}
@override
Widget build(BuildContext context) {
return ListView(controller: _controller, children: [...]);
}
@override
void didUpdateWidget(covariant ProfileScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// Called when the parent rebuilds this widget with new configuration
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}What each stage is actually for
- initState — runs once, before the first build. Set up controllers, subscriptions, animation controllers here. Never call
Provider.of(context)here withoutlisten: false— the widget tree isn't fully established yet. - didChangeDependencies — runs after initState and again whenever an inherited widget it depends on changes (theme, locale, MediaQuery). This is where you should read
InheritedWidget/Theme.of(context)values that need to react to changes. - build — called potentially many times per second. Anything expensive here (network calls, heavy computation) is a performance bug waiting to happen.
- didUpdateWidget — fires when the parent passes new configuration to an already-mounted widget (e.g. a new
userIdprop). This is where you re-fetch data tied to changed parameters — a very common miss that causes screens to silently show stale data. - dispose — release everything: controllers, stream subscriptions, animation controllers. Forgetting this is the #1 cause of "setState called after dispose" crashes.
The classic bug: setState after dispose
void _loadData() async {
final data = await api.fetchProfile();
if (!mounted) return; // guard — the widget may have been disposed while awaiting
setState(() => _profile = data);
}Any async gap (an await) is a point where the widget could be disposed before it resumes. Always check mounted after an await, before calling setState.
Practical checklist
- Every controller created in
initStategets disposed indispose— no exceptions. - Data tied to widget parameters gets refreshed in
didUpdateWidget, not assumed to be re-fetched automatically. - Guard every post-
awaitsetStatewith amountedcheck. - Keep
buildpure and cheap — it will run more often than you expect.