05 · StatefulWidget, setState & the State Lifecycle¶
A StatelessWidget can only describe what it's given. Anything that changes on its own — a
counter, a text field's contents, a countdown, an animation — needs state: data that lives
longer than one build call. Flutter splits a stateful widget into two classes: the immutable
StatefulWidget (configuration, recreated on every parent rebuild) and a long-lived State
object (the mutable data, kept across rebuilds). This lesson logs every lifecycle method of a
real State under a widget test, so you can see exactly when each runs — and what goes wrong
when cleanup is forgotten.
A countdown that logs its lifecycle¶
import 'dart:async';
import 'package:flutter/material.dart';
final lifecycleLog = <String>[];
/// A countdown that ticks once per second and reports each lifecycle call.
class Countdown extends StatefulWidget {
const Countdown({super.key, required this.from, this.onDone});
final int from;
final VoidCallback? onDone;
@override
State<Countdown> createState() {
lifecycleLog.add('createState');
return _CountdownState();
}
}
class _CountdownState extends State<Countdown> {
late int _remaining;
Timer? _timer;
@override
void initState() {
super.initState();
lifecycleLog.add('initState(from=${widget.from})');
_remaining = widget.from;
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void _tick() {
setState(() => _remaining--);
if (_remaining == 0) {
_timer?.cancel();
widget.onDone?.call();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
lifecycleLog.add('didChangeDependencies');
}
@override
void didUpdateWidget(Countdown oldWidget) {
super.didUpdateWidget(oldWidget);
lifecycleLog.add('didUpdateWidget(${oldWidget.from} -> ${widget.from})');
if (oldWidget.from != widget.from) {
_remaining = widget.from; // parent asked for a new countdown
}
}
@override
void dispose() {
lifecycleLog.add('dispose');
_timer?.cancel(); // without this, the timer keeps calling setState on a dead State
super.dispose();
}
@override
Widget build(BuildContext context) {
lifecycleLog.add('build($_remaining)');
return Text('$_remaining', style: Theme.of(context).textTheme.displaySmall);
}
}
The structure to memorise:
Countdown(the widget) holds configuration:fromandonDone. It'sconst-constructible and immutable.createState()creates the_CountdownState— once per place in the tree, not once per build. (Logging insidecreateStateis only for this demonstration;flutter analyzeflags it withno_logic_in_create_state, and real code should do nothing there but return the state.)_CountdownStateholds_remainingand theTimer. It reads its widget's configuration through thewidgetgetter.setState(fn)runsfnto change fields and schedules a rebuild. It doesn't rebuild immediately.
The lifecycle, observed¶
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hello/l1/lifecycle.dart';
Widget app(Widget child) => MaterialApp(home: Scaffold(body: Center(child: child)));
void main() {
setUp(lifecycleLog.clear);
testWidgets('lifecycle of a State', (tester) async {
var done = false;
await tester.pumpWidget(app(Countdown(from: 3, onDone: () => done = true)));
print('mount: $lifecycleLog'); lifecycleLog.clear();
await tester.pump(const Duration(seconds: 1));
print('after 1s: $lifecycleLog text=${(tester.widget<Text>(find.byType(Text))).data}');
lifecycleLog.clear();
// Parent rebuilds with new configuration: same State, didUpdateWidget.
await tester.pumpWidget(app(Countdown(from: 5, onDone: () => done = true)));
print('new config: $lifecycleLog'); lifecycleLog.clear();
await tester.pump(const Duration(seconds: 5));
print('after 5s more: $lifecycleLog done=$done'); lifecycleLog.clear();
// Removing the widget disposes the State.
await tester.pumpWidget(app(const SizedBox()));
print('removed: $lifecycleLog');
});
testWidgets('different type at the same spot = new State', (tester) async {
await tester.pumpWidget(app(const Countdown(from: 3)));
lifecycleLog.clear();
await tester.pumpWidget(app(const Padding(padding: EdgeInsets.zero, child: Countdown(from: 3))));
print('wrapped in Padding: $lifecycleLog');
await tester.pumpWidget(app(const SizedBox()));
});
}
$ flutter test test/l1/lifecycle_test.dart
mount: [createState, initState(from=3), didChangeDependencies, build(3)]
after 1s: [build(2)] text=2
new config: [didUpdateWidget(3 -> 5), build(5)]
after 5s more: [build(0)] done=true
removed: [dispose]
wrapped in Padding: [createState, initState(from=3), didChangeDependencies, build(3), dispose]
00:00 +2: All tests passed!
(tester.pump(duration) advances the test's fake clock, so timers fire instantly — a five-second
countdown takes milliseconds to test.)
| Call | When | Use it for |
|---|---|---|
createState |
widget inserted at a new place | creating the State (nothing else) |
initState |
once, right after creation | one-time setup: controllers, timers, subscriptions |
didChangeDependencies |
after initState, and whenever an inherited widget it depends on changes |
work that needs Theme.of, MediaQuery.of, etc. |
build |
every time the state is dirty or its parent rebuilds | describing UI — nothing else |
didUpdateWidget |
parent rebuilt with a new widget of the same type | reacting to changed configuration |
dispose |
permanently removed | cancel timers, close streams, dispose controllers |
Four observations from the run:
- New configuration keeps the same
State. When the parent pumpedCountdown(from: 5), Flutter didn't create a new state; it calleddidUpdateWidget(3 -> 5). If the state had ignored that, the countdown would have kept counting from 2. Whenever a field derived fromwidgetshould follow changes, handledidUpdateWidget. - Several
setStatecalls, one build. Advancing five seconds fired the timer five times, each callingsetState, but only onebuild(0)was logged.setStatemarks the state dirty; the rebuild happens once, at the next frame. You never need to "batch"setStatecalls yourself. disposeon removal. Replacing the countdown with aSizedBoxdisposed its state.- Changing the tree's shape loses state. Wrapping the same
Countdownin aPaddingcreated a new state (createState,initState…) and disposed the old one. At that position the old element was aCountdown, the new widget is aPadding— different types — so the subtree was rebuilt from scratch. Conditional wrappers (showBorder ? Padding(child: x) : x) silently reset state for exactly this reason; lesson 07 shows how keys interact with it.
The bug every Flutter developer writes once¶
Comment out _timer?.cancel() in dispose, mount the countdown, remove it, and let one second pass:
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown running a test:
setState() called after dispose(): _CountdownState#db9a2(lifecycle state: defunct, not mounted)
This error happens if you call setState() on a State object for a widget that no longer appears in
the widget tree (e.g., whose parent widget no longer includes the widget in its build). This error
can occur when code calls setState() from a timer or an animation callback.
The preferred solution is to cancel the timer or stop listening to the animation in the dispose()
callback. Another solution is to check the "mounted" property of this object before calling
setState() to ensure the object is still in the tree.
...
The timer held a reference to the dead State and kept calling _tick. The message is unusually
helpful: cancel in dispose. Checking if (!mounted) return; before setState is the right fix for
a one-off async callback (an awaited network call that finishes after the user navigated away),
but for repeating sources — timers, stream subscriptions, animation controllers — cancel them in
dispose, or they leak.
Where should state live?¶
setState is perfect for state that belongs to one widget and nothing else cares about: whether a
panel is expanded, the current text in a search box, a countdown's value. When several widgets need
the same data, or it must survive navigation, it moves up the tree or into a state-management
solution — Level 2 covers lifting state, InheritedWidget, Provider,
Riverpod and Bloc. A useful rule: start with setState, and move state out when a second widget needs
it.
How It Actually Works¶
A StatefulWidget creates a StatefulElement. When the element is mounted, it calls
widget.createState(), sets state._widget and state._element, and calls initState then
didChangeDependencies, then builds. The element keeps the same State for as long as the element
lives. When a parent rebuilds and Widget.canUpdate(old, new) holds (same type and key), the element
stores the new widget, calls state.didUpdateWidget(oldWidget), and rebuilds.
setState(fn) calls fn synchronously, then _element.markNeedsBuild(), which adds the element to the
owner's dirty list and asks the engine for a frame if one isn't already scheduled. During the next
frame's build phase, each dirty element rebuilds once — hence the coalescing. When an element is
removed, it's first deactivated (it could still be re-inserted elsewhere in the same frame, e.g.
with a GlobalKey); if it isn't reused by the end of the frame, it's unmounted and dispose runs.
After that, mounted is false and setState asserts, as seen above.
Common mistakes¶
- Not cancelling timers, subscriptions or controllers in
dispose. - Initialising state from
widgetininitStateand never updating it indidUpdateWidget. - Calling
Theme.of(context)orMediaQuery.of(context)ininitState— usedidChangeDependenciesorbuild. - Doing async work in
setState's callback. The callback must be synchronous; await first, then callsetStatewith the result (checkingmounted). - Conditionally wrapping a stateful widget, losing its state whenever the condition flips.
Exercise¶
- Add a "Pause" button that stops and restarts the timer. Where do you store whether it's paused?
- Remove
didUpdateWidgetfrom_CountdownStateand change the test to expect the bug: what number is shown after the parent switchesfromto 5? - Make the
Paddingwrapper conditional on abooland write a test that proves toggling it resets the countdown. Then fix it by always including thePadding(withEdgeInsets.zerowhen off). - Write a widget that awaits a fake two-second "network"
FutureininitStateand shows the result. Write a test that removes it after one second and confirm nosetStateerror occurs thanks to amountedcheck.