Lifecycle
Two lifecycles matter: the application's, driven by the operating system, and a
State object's, driven by the framework. Most resource leaks and "setState
after dispose" crashes come from confusing the two.
Contents
- Application lifecycle
- Observing app lifecycle
- State lifecycle
- initState
- didChangeDependencies
- didUpdateWidget
- dispose
- mounted
- When State is reused
- Checklist
Application lifecycle
AppLifecycleState describes the app as the OS sees it:
| State | Meaning |
|---|---|
resumed | Visible and receiving input |
inactive | Visible but not receiving input — a call arrives, the app switcher opens, a system dialog is up |
hidden | About to be paused; emitted on all platforms before paused |
paused | Not visible, still running, no input |
detached | The Flutter engine is running without a view; the process may be about to end |
Platform differences matter. On Android, paused is reliable and detached
often is not reached before the process dies. On iOS, an app can be terminated
from paused with no further callback. Never rely on detached to save
data — persist at inactive or paused.
Observing app lifecycle
The modern listener API takes the callbacks you need:
class _HomeState extends State<Home> {
late final AppLifecycleListener _listener;
@override
void initState() {
super.initState();
_listener = AppLifecycleListener(
onResume: _resumeVideo,
onInactive: _saveDraft,
onPause: _stopSensors,
onStateChange: (state) => debugPrint('lifecycle: $state'),
);
}
@override
void dispose() {
_listener.dispose();
super.dispose();
}
}
The older observer approach still works and is what you will see in existing code:
class _HomeState extends State<Home> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) _saveDraft();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
WidgetsBindingObserver also delivers didChangeMetrics (window resize,
keyboard), didChangeLocales, didChangePlatformBrightness, and
didHaveMemoryPressure — the last is a good place to clear image caches.
Typical work at each transition:
| Transition | Do |
|---|---|
To inactive / paused | Save drafts, pause video and animation, stop location and sensors, release the camera |
To resumed | Refresh stale data, re-check auth token, restart streams, re-acquire hardware |
State lifecycle
createState()
└─ initState()
└─ didChangeDependencies()
└─ build() ←──────────────┐
├─ didUpdateWidget() ─┤ (parent rebuilds with a new widget)
├─ setState() ────────┤ (internal change)
└─ didChangeDependencies() (inherited widget changed)
...
deactivate()
└─ dispose()
initState
Called once, before the first build. The widget is available as widget, but
context cannot be used for inherited lookups yet.
@override
void initState() {
super.initState(); // always first
_controller = AnimationController(vsync: this, duration: _kDuration);
_subscription = repository.watchOrders().listen(_onOrders);
_scrollController.addListener(_onScroll);
}
Do not await in initState — it is not async. Kick off the work and handle
completion defensively:
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final data = await repository.load();
if (!mounted) return; // the widget may be gone
setState(() => _data = data);
}
didChangeDependencies
Called after initState, and again whenever an inherited widget this element
depends on changes. This is the first place Theme.of, MediaQuery.of, or a
provider lookup is legal.
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = Localizations.localeOf(context);
if (locale != _locale) {
_locale = locale;
_reloadTranslations();
}
}
Guard the work — this can fire more often than you expect.
didUpdateWidget
Called when the parent rebuilds with a new widget instance of the same type. The
State object survives; the widget does not. React to changed configuration
here.
@override
void didUpdateWidget(covariant VideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.url != oldWidget.url) {
_controller.dispose();
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.url))
..initialize();
}
}
This is why copying widget.x into a State field in initState is a bug
unless you also update it here — the field goes stale as soon as the parent
passes a new value.
dispose
Called once, when the State is removed permanently. Release everything you
acquired, in reverse order.
@override
void dispose() {
_subscription?.cancel();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_animationController.dispose();
_focusNode.dispose();
_textController.dispose();
_timer?.cancel();
super.dispose(); // always last
}
Things that leak if you forget: AnimationController (keeps a ticker running
every frame), StreamSubscription, Timer, ScrollController,
TextEditingController, FocusNode, ChangeNotifier listeners, platform
channel handlers, and isolate ports.
deactivate runs before dispose and can be followed by re-insertion elsewhere
in the tree (a GlobalKey move), so it is not the place to release resources.
mounted
mounted is true between initState and dispose. Check it after every
await before touching setState or context:
Future<void> _submit() async {
final result = await api.send(_form);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result)));
}
For a BuildContext captured outside a State, use context.mounted. The
use_build_context_synchronously lint flags most of these cases — leave it on.
When State is reused
The framework reuses an Element (and therefore its State) when the new
widget has the same runtimeType and the same key. Change either and the
old state is disposed and a new one created.
// Same type, no key: State is reused; text field keeps its content
_showingA ? const NameField() : const NameField()
// Distinct keys: State is recreated; text field resets
_showingA
? const NameField(key: ValueKey('a'))
: const NameField(key: ValueKey('b'))
This is the mechanism behind the classic reordering bug: swap two items in a
list of stateful widgets without keys and their state stays in place while their
data moves. Adding a ValueKey tied to the item's identity fixes it. Details in
Rendering pipeline.
Checklist
- Acquire in
initState, release indispose, react to new configuration indidUpdateWidget. - Use
contextfor inherited lookups fromdidChangeDependenciesonward, never ininitState. - Guard every post-
awaitsetStatewithmounted. - Save user data on
inactive/paused, not ondetached. - Stop animations, video, sensors, and polling when the app is backgrounded — they cost battery and, on some platforms, get you throttled.
- Verify with the memory view in
DevTools and performance: navigate into a
screen and back several times, then check that the
Stateobjects were collected.
Next: Rendering pipeline.