A Flutter barometer widget listens to a pressure stream in initState and later logs 'setState() called after dispose()'; what went wrong, and how should it be structured?
answer
- the listener outlives the State
- keep the StreamSubscription
- cancel it in dispose
- swap it in didUpdateWidget
- mounted guards gaps, not listeners
basics
~20 sThe stream subscription was never cancelled, so readings keep arriving after the State was disposed and each one calls setState. Keep the StreamSubscription, cancel it in dispose before super.dispose(), and swap it in didUpdateWidget when the stream changes.
solid answer
~50 s`dispose` does not stop anything you started; it only tells you the State will never build again. The listener registered in `initState` still holds a closure over the State, so the next sensor reading calls `setState` on a defunct State, which debug builds report as 'setState() called after dispose()', and the framework's own hint warns this can indicate a memory leak. The fix is the pattern the `initState` documentation spells out: subscribe in `initState`, keep the `StreamSubscription`, in `didUpdateWidget` cancel and resubscribe when `widget.readings` changed, and in `dispose` cancel it before `super.dispose()`. Checking `mounted` inside the listener only hides the error while the subscription keeps running and keeps the State reachable; `mounted` is for gaps such as an awaited call, not a substitute for cancelling. For display-only cases, a `StreamBuilder` manages the subscription for you.
code
dart · 50 linesimport 'dart:async';
import 'package:flutter/material.dart';
class Barometer extends StatefulWidget {
const Barometer({super.key, required this.readings});
final Stream<double> readings; // hPa values from any sensor source
@override
State<Barometer> createState() => _BarometerState();
}
class _BarometerState extends State<Barometer> {
StreamSubscription<double>? _subscription;
double? _hPa;
@override
void initState() {
super.initState();
_subscribe();
}
@override
void didUpdateWidget(Barometer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.readings != widget.readings) {
_subscription?.cancel();
_subscribe();
}
}
void _subscribe() {
_subscription = widget.readings.listen((value) {
setState(() => _hPa = value);
});
}
@override
void dispose() {
_subscription?.cancel(); // stops readings before the State is finished
super.dispose();
}
@override
Widget build(BuildContext context) {
final hPa = _hPa;
return Text(hPa == null ? 'Waiting for sensor' : '${hPa.toStringAsFixed(1)} hPa');
}
}go deeper
Recall that anything you start in initState, such as a stream subscription, must be cancelled in dispose.
Explain the subscribe, swap, cancel pattern across initState, didUpdateWidget and dispose, and what the mounted flag does and does not protect.
Diagnose 'setState() called after dispose()' in production code, trace it to the retaining listener, and fix the lifecycle rather than masking it with mounted checks.
Push resource ownership into reusable patterns such as StreamBuilder or a disposing wrapper, so every screen does not hand-roll teardown and reintroduce the bug.
## What the error says In debug builds, `setState` on a disposed State throws 'setState() called after dispose()'. The framework's description lists the usual sources: a timer, an animation callback, or an asynchronous operation that completes after the widget has been removed. Its hints add that the preferred solution is to cancel the timer or stop listening in `dispose()`, that checking `mounted` is another option, and that the error 'might indicate a memory leak' if another object keeps a reference to the State. ## Why it happens - `Stream.listen` returns a `StreamSubscription` that stays active until it is cancelled or the stream closes. - The `onData` closure refers to `setState`, so the subscription keeps the State object reachable. - `dispose` is a notification, not a cleanup: the framework calls it and marks the State defunct, but it knows nothing about your subscription. - The next reading arrives, the closure calls `setState`, and the assertion fires. In release builds the call fails as well, because the State no longer has an element. The leak angle, finding such retained objects with memory tooling, is a separate topic; the lifecycle fix is what matters here. ## The correct structure The `initState` documentation gives the pattern for any object you subscribe to, whether a `Stream` or a `ChangeNotifier`: 1. **initState**: subscribe, and keep the handle in a field. 2. **didUpdateWidget**: if the configuration now points at a different source, cancel the old subscription and subscribe to the new one. 3. **dispose**: cancel, then call `super.dispose()` last. Do not cancel in `deactivate`: a deactivated State can be reinserted within the same frame, and the framework documents that States can defer releasing most resources until `dispose`. ## The mounted flag `mounted` is true from the moment the State is attached to its element, before `initState`, until `dispose`, after which it is false forever. It answers one question: may I call `setState` now? It is the right guard after a gap you cannot cancel, such as an awaited one-off request. It is the wrong fix for a subscription, because: - the listener keeps running and doing work for a screen that no longer exists; - the closure keeps the defunct State reachable, which is the leak the framework warns about; - the underlying sensor may stay active and drain the battery. ## A lower-effort alternative When the stream only feeds the display, `StreamBuilder` does this bookkeeping itself: its State subscribes in `initState`, resubscribes in `didUpdateWidget` when it receives a different stream, and unsubscribes in `dispose`. You still own creating the stream, and it must not be created inside `build`, or every rebuild hands the builder a new stream. ## Review checklist | Resource created in `initState` | Released in `dispose` by | |---|---| | `StreamSubscription` | `cancel()` | | `Timer` | `cancel()` | | `AnimationController`, `TextEditingController` | `dispose()` | | Listener added to a `Listenable` | `removeListener(...)` | For each row, also ask whether `didUpdateWidget` must swap it when a parameter changes.
- Why not cancel the subscription in deactivate instead of dispose?`deactivate` means removed for now, not gone. If the subtree is grafted elsewhere in the same frame, typically through a `GlobalKey`, the framework calls `activate` and builds again, and a subscription cancelled in `deactivate` would be missing unless you re-created it in `activate`. The framework documents that States can defer releasing most resources until `dispose`.
- Would a StreamBuilder have avoided this bug?For a display-only widget, yes: `StreamBuilder` subscribes in its own `initState`, resubscribes when handed a different stream, and unsubscribes in its `dispose`. You still have to create the stream outside `build`, typically in a parent State or a service, because a stream created in `build` is a new object on every rebuild and forces a resubscribe each time.
saying these in an interview costs you the question
- Garbage collection cancels the subscription once the widget is gone
- Checking mounted inside the listener is the complete fix
- Cancel stream subscriptions in deactivate to free them early
- dispose cancels subscriptions the State created automatically
- The error is harmless debug noise that can be ignored