Flutter setState Called After dispose: Causes and How to Prevent It
You tap a button, fire off a network request, and your app crashes with setState() called after dispose(). Or worse, the app doesn't crash in debug mode but silently drops state updates in production. Either way, you have an async operation that completed after its widget was already torn down.
This error is one of the most common lifecycle bugs in Flutter. It's also one of the most preventable once you understand what's actually happening under the hood.
What This Error Actually Means
When Flutter removes a StatefulWidget from the tree, it calls dispose() on the associated State object. After that point, the framework considers the state object dead. Calling setState() on a disposed state will throw an assertion error in debug builds and can cause undefined behavior in release builds.
The full error message looks like this:
FlutterError (setState() called after dispose():
_MyWidgetState#3f2a1(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).
The key phrase is no longer mounted. A widget's state is mounted between initState() and dispose(). Any setState() call outside that window is illegal.
What you'll learn
- Why async gaps between
awaitcalls are the primary trigger for this error - How streams, timers, and animation controllers can outlive their widgets
- The correct way to use the
mountedcheck (and its one big gotcha) - A cancellation flag pattern for complex async flows
- How lifting state out of the widget entirely eliminates the problem class
The Widget Lifecycle Behind the Bug
Every StatefulWidget goes through a predictable sequence: createState() β initState() β build() β (optionally) didUpdateWidget() β dispose(). The framework sets an internal _mounted flag to true after initState() and to false during dispose().
The problem is that Dart's async model doesn't know or care about this flag. A Future you started in initState() will complete on its own schedule, long after the user has already navigated away and the widget has been disposed. The future's callback fires, calls setState(), and the framework throws.
If you're already comfortable with similar problems in other ecosystems, you'll recognize this as the Flutter equivalent of calling setState() on an unmounted React component β a pattern that React has its own lifecycle guards against.
The Most Common Trigger: Async Gaps
The single most frequent cause is an async method that awaits a Future and then calls setState() after the gap. Here's a minimal reproduction:
class _ProfilePageState extends State<ProfilePage> {
String? _username;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
final user = await fetchUserFromApi(); // β await gap here
setState(() { // β widget may be disposed by now
_username = user.name;
});
}
@override
Widget build(BuildContext context) {
return Text(_username ?? 'Loading...');
}
}
If the user navigates back before fetchUserFromApi() resolves, dispose() runs. When the future eventually completes, _loadUser() resumes after the await and hits setState() on a dead state object.
Every await is a potential gap where your widget can disappear. Treat each one as a point where you must verify the widget is still alive.
Streams and Subscriptions Left Open
Streams are a subtler version of the same problem. If you subscribe to a stream inside initState() but forget to cancel the subscription in dispose(), the stream keeps delivering events to a dead widget.
class _StockTickerState extends State<StockTicker> {
StreamSubscription<double>? _sub;
double _price = 0.0;
@override
void initState() {
super.initState();
// BAD: no cancellation in dispose
_sub = priceStream().listen((price) {
setState(() => _price = price);
});
}
@override
void dispose() {
_sub?.cancel(); // β this line is the fix
super.dispose();
}
@override
Widget build(BuildContext context) => Text('\$$_price');
}
The fix is always the same: store the StreamSubscription, call cancel() in dispose(), and call super.dispose() last. This pattern maps directly to the memory leak problem class described in long-running process memory leak analysis β a subscription that isn't cleaned up is a leak, even in a garbage-collected language.
Timers and Animation Controllers That Outlive Their Widget
Periodic timers are another common offender. A Timer.periodic that isn't cancelled in dispose() will keep ticking and calling setState() indefinitely.
class _CountdownState extends State<Countdown> {
Timer? _timer;
int _seconds = 30;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_seconds > 0) {
setState(() => _seconds--);
} else {
_timer?.cancel();
}
});
}
@override
void dispose() {
_timer?.cancel(); // always cancel before super.dispose()
super.dispose();
}
@override
Widget build(BuildContext context) => Text('$_seconds');
}
AnimationController objects work exactly the same way: always call controller.dispose() inside your widget's dispose(). Skipping this also leaks the Ticker, which Flutter will warn you about with a separate assertion.
For deeper context on how blocking the main thread relates to widget responsiveness, see fixing Flutter platform channel calls that block the main UI thread.
How to Guard with mounted
For async futures you can't cancel (HTTP requests, for instance), the mounted property is your guard. Check it immediately after every await before touching state.
Future<void> _loadUser() async {
final user = await fetchUserFromApi();
if (!mounted) return; // β bail out if widget is gone
setState(() {
_username = user.name;
});
}
This is simple and effective. The mounted getter reads the same internal flag the framework uses, so it's always accurate at the moment you read it.
The gotcha: the async gap between check and call
Checking mounted and then calling setState() is safe in practice, but be aware that in theory there's still a micro-gap between the check and the call. In real Flutter apps this is not a problem because disposal happens on the main thread and Dart is single-threaded. The check-then-call pattern is the officially recommended approach.
What is a real problem is checking mounted once at the top of a long async method and then awaiting again further down. Each await is a new gap.
// BAD: mounted check doesn't cover the second await
Future<void> _load() async {
if (!mounted) return;
final a = await stepOne();
final b = await stepTwo(); // β new gap, widget could be gone here
setState(() { /* ... */ });
}
// GOOD: check after every await that precedes a setState
Future<void> _load() async {
final a = await stepOne();
if (!mounted) return;
final b = await stepTwo();
if (!mounted) return;
setState(() { /* ... */ });
}
Using a Cancellation Flag for Complex Flows
When you have multiple async operations chained together and checking mounted everywhere feels cluttered, a boolean cancellation flag gives you cleaner control.
class _DashboardState extends State<Dashboard> {
bool _cancelled = false;
List<Item> _items = [];
@override
void initState() {
super.initState();
_fetchAll();
}
Future<void> _fetchAll() async {
final userData = await fetchUser();
if (_cancelled) return;
final items = await fetchItems(userData.id);
if (_cancelled) return;
final enriched = await enrichItems(items);
if (_cancelled) return;
setState(() => _items = enriched);
}
@override
void dispose() {
_cancelled = true;
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView(
children: _items.map((i) => Text(i.title)).toList(),
);
}
}
Setting _cancelled = true in dispose() means every subsequent check in the async chain will bail out cleanly. This is easier to read than sprinkling if (!mounted) return everywhere, and it works even when the async logic is extracted into a helper method that doesn't have direct access to the State object's mounted property.
Extracting Logic Into a Separate Class or Provider
The cleanest long-term fix is to move async logic out of the widget entirely. When a ChangeNotifier, Riverpod provider, or BLoC owns the state, the widget just rebuilds in response to notifications. It never calls setState() itself, so there's nothing to call after dispose.
class UserNotifier extends ChangeNotifier {
String? username;
bool loading = true;
UserNotifier() {
_load();
}
Future<void> _load() async {
final user = await fetchUserFromApi();
username = user.name;
loading = false;
notifyListeners(); // safe β notifier outlives the widget
}
}
// In your widget:
class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final notifier = context.watch<UserNotifier>();
return notifier.loading
? const CircularProgressIndicator()
: Text(notifier.username ?? '');
}
}
notifyListeners() doesn't care whether any widget is currently listening. It simply notifies whoever is registered. If the widget is gone, there are no listeners and nothing breaks. This approach also makes the logic unit-testable without a widget tree.
If you're earlier in your Flutter journey and want a broader view of how the ecosystem fits together, the Flutter developer learning roadmap covers state management options in context.
Common Pitfalls to Avoid
- Calling super.dispose() before cancelling resources. Always cancel timers, subscriptions, and controllers before calling
super.dispose(). Aftersuper.dispose(), the state's internal flag is cleared and some framework assertions may fire. - Using BuildContext across async gaps. After any
await,contextmay no longer be valid. Checkmountedfirst, and prefer storing what you need fromcontextin a local variable before the firstawait. - Relying on try/catch to swallow the error. Catching the assertion error and ignoring it doesn't fix the underlying race condition; it just hides it. The async work still ran unnecessarily, and the state update was silently dropped.
- Not disposing AnimationController. The framework tracks active tickers and will print a warning in debug mode if you forget. In release builds it's a silent leak that degrades performance over time.
- Thinking the error only happens on navigation. Widget subtrees can be conditionally removed from the build tree by a parent widget's
setState()call. Your widget doesn't have to navigate away to get disposed.
Wrapping Up
The setState() called after dispose() error is a symptom of one root cause: async work that outlived the widget that started it. Once you see your code through that lens, the fixes are straightforward.
Here are the concrete steps to take right now:
- Audit every
initState()method in your codebase. If it starts an async operation, make sure there's a corresponding cancellation or guard indispose(). - Add
if (!mounted) returnimmediately after everyawaitthat precedes asetState()call. Make it a reflex. - Store and cancel all
StreamSubscription,Timer, andAnimationControllerobjects indispose(), before callingsuper.dispose(). - Use a
_cancelledflag when you have long async chains where repeatedmountedchecks hurt readability. - Consider moving async state into a
ChangeNotifieror state management solution for any feature with non-trivial async logic. This eliminates the problem class entirely and makes the code easier to test.
Frequently Asked Questions
Why does Flutter throw setState called after dispose only sometimes?
The error depends on timing: if the async operation completes before the widget is disposed, no error occurs. It only throws when the future or stream callback fires after dispose() has already run, which depends on how long the async work takes and how quickly the user navigates away.
Is it safe to check mounted and then call setState immediately after?
Yes, this is the recommended Flutter pattern. Since Dart is single-threaded and disposal happens on the main thread, there is no real race condition between checking mounted and calling setState in the very next line.
Does using a ChangeNotifier or Riverpod provider prevent setState after dispose errors?
Yes. When async logic lives in a ChangeNotifier or provider, the widget calls no setState itself and simply rebuilds in response to notifications. If the widget is gone when notifyListeners fires, there are no registered listeners and nothing throws.
Should I use mounted or a cancellation flag to guard async setState calls?
Use mounted for simple single-await flows β it's concise and accurate. Use a boolean cancellation flag when you have multiple awaits chained together, since it keeps the check consistent across the whole operation and also works inside helper methods that don't have access to the State object directly.
Can I get this error without navigating away from the screen?
Yes. Any time a parent widget conditionally removes your widget from the build tree β for example by toggling a boolean in its own setState β your widget's dispose is called immediately. You don't need full navigation for the widget to be torn down.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!