Skip to main content

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

AppLifecycleState describes the app as the OS sees it:

StateMeaning
resumedVisible and receiving input
inactiveVisible but not receiving input — a call arrives, the app switcher opens, a system dialog is up
hiddenAbout to be paused; emitted on all platforms before paused
pausedNot visible, still running, no input
detachedThe 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:

TransitionDo
To inactive / pausedSave drafts, pause video and animation, stop location and sensors, release the camera
To resumedRefresh 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 in dispose, react to new configuration in didUpdateWidget.
  • Use context for inherited lookups from didChangeDependencies onward, never in initState.
  • Guard every post-await setState with mounted.
  • Save user data on inactive/paused, not on detached.
  • 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 State objects were collected.

Next: Rendering pipeline.