Bidev

Widget Lifecycle Bugs: setState After Dispose and Friends

Bilal Fali4 min read
Widget Lifecycle Bugs: setState After Dispose and Friends

Every Flutter developer eventually ships a screen with a network call and a back button on it, and eventually someone taps back before the call finishes. That's the whole bug. Not a race condition in the exotic sense, just two clocks running at different speeds: your widget tree, which can be torn down the instant the user navigates away, and your async code, which keeps running until it's done whether the widget is still around to hear about it or not.

I've seen teams treat this as a one-line fix (add a mounted check, move on) and then get surprised when the exception comes back a few sprints later from a slightly different code path. It comes back because the mounted check was put in the wrong place, or because it was the only defense against a whole category of lifecycle bugs that also includes timers and stream subscriptions.

The mounted check has to happen right before you touch state, not before the await

This is the most common mistake, and it's subtle enough that it survives code review constantly:

Future<void> _loadData() async {
  if (!mounted) return; // checked too early
  final result = await fetchData();
  setState(() {
    _data = result;
  });
}

That check is almost useless. The widget can be disposed at any point during the await, which is exactly when the user navigating away actually happens. The check needs to sit immediately before the code that touches the widget's state, after the await has resolved:

Future<void> _loadData() async {
  final result = await fetchData();
  if (!mounted) return; // checked where it matters
  setState(() {
    _data = result;
  });
}

If you're calling something that needs BuildContext after an await, Flutter 3.7 added context.mounted specifically for this, and it's worth using directly instead of the widget's own mounted getter when you're deep in a callback that only has access to the context:

Future<void> _submit() async {
  await someAsyncCall();
  if (!context.mounted) return;
  Navigator.of(context).pop();
}

Mounted checks don't help with timers or subscriptions that never stop

A Timer.periodic or a StreamSubscription doesn't ask permission before firing its callback. If you never cancel it, it keeps calling your listener after the widget is gone, and no amount of mounted-checking inside _loadData() will save you, because the crash happens in a completely different callback that you probably didn't think to guard.

class _MyWidgetState extends State<MyWidget> {
  StreamSubscription? _subscription;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 5), (_) => _poll());
    _subscription = someStream.listen(_onData);
  }

  @override
  void dispose() {
    _timer?.cancel();
    _subscription?.cancel();
    super.dispose();
  }
}

This is the part that actually gets skipped in real codebases, not the mounted check. Everyone remembers to check mounted because the exception message tells them to. Almost nobody remembers to audit every Timer and StreamSubscription in a widget for a matching cancel call in dispose(), because there's no exception forcing the issue until a user hits it in a way that happens to trigger the leaked callback.

If you're seeing this across many screens, fix the pattern, not each screen

When this bug shows up once, a mounted check is the right fix. When it shows up repeatedly across a codebase, that's a signal the team doesn't have a consistent convention for cleanup, and patching screens one at a time as crash reports come in is a losing game. A base class or mixin that centralizes "cancel everything in dispose" is worth the setup cost once you're past three or four screens with the same shape of bug.

The underlying lesson holds regardless of which specific fix applies: Flutter's widget tree and Dart's async model don't share a clock. Every async operation that outlives a single frame is a potential dispose-timing bug, and the fix is always some version of "check before you touch the widget, and stop anything that keeps calling back after it's gone."

If you want a shorter version of the mounted-check fix specifically, see the setState after dispose troubleshooting entry for the quick version without the full explanation.

Share this

Skip the boilerplate

Production-ready Flutter starter kit with Firebase Auth, Firestore, Cloud Functions, push notifications, and Clean Architecture — ship your app in days, not months.

Get Flutter Firebase Kit$5

Did this article save you time?

I write these for free. If it helped, a coffee keeps me going — and more articles coming.

Buy me a coffee

Comments

Comments

Leave a comment

0/2000

Comments appear after review.