Updated June 2026. Tested on Flutter 3.x and Dart 3. Part of the Techalyst Flutter series.
A stateful widget is not just a build method. Its State object goes through a lifecycle, a handful of methods Flutter calls at specific moments: when it is created, when it updates, and when it is removed. Knowing the main ones tells you where to start a timer, where to fetch data, and, most importantly, where to clean up so your app does not leak.
The methods, in order
For a typical stateful widget, these run in this sequence:
initState is called once, right when the State is created, before the first build. This is where you set up things that should exist for the life of the widget: controllers, stream subscriptions, animation controllers, an initial data fetch. Always call super.initState() first.
didChangeDependencies runs straight after initState, and again whenever an InheritedWidget this widget depends on changes. Unlike initState, it is safe to use context here for things like Theme.of(context), because the widget is now placed in the tree.
build runs on every rebuild. Keep it pure: it should only describe UI, never start timers or fire requests, because it can run many times.
didUpdateWidget is called when the parent rebuilds this widget with a new configuration. You get the oldWidget so you can compare and react, for example restarting something if a key property changed.
dispose is called once, when the widget is removed from the tree for good. This is where you tear down everything you set up. Call super.dispose() last.
The rule that prevents leaks
Here is the one habit that matters most: anything you create in initState, undo in dispose. Timers, subscriptions, controllers, and focus nodes all hold resources, and if you do not release them they keep running and keep memory alive long after the screen is gone.
class _ClockState extends State<Clock> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() {}); // tick
});
}
@override
void dispose() {
_timer?.cancel(); // stop it before we go
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(DateTime.now().toString());
}
}
Without that cancel, the timer keeps firing forever, even after you leave the screen. The same applies to the stream subscriptions from streams and StreamBuilder and the text controllers from any form. Set up in initState, clean up in dispose, every time.
Two mistakes to avoid
Calling setState after a widget is disposed throws, which happens when an async call finishes after the user has left the screen. Guard it with the mounted flag:
final data = await fetchData();
if (!mounted) return; // widget is gone, do not touch state
setState(() => _data = data);
The other one is forgetting the super calls. super.initState() goes first, super.dispose() goes last. Skip them and Flutter will complain, sometimes in confusing ways.
Wrapping up
The stateful lifecycle is short and worth memorising. initState sets things up once, didChangeDependencies is where context becomes safe to use, build stays pure and runs often, didUpdateWidget reacts to new configuration, and dispose tears everything down. The whole thing reduces to one discipline: whatever you create in initState, release in dispose, and check mounted before calling setState from an async callback. Follow that and the lifecycle stops being a mystery and your app stops leaking.
All comments ()
No comments yet
Be the first to leave a comment on this post.