Eliminating 'setState() called after dispose()' in Flutter Navigator 2.0 and Async Flows
When you mix Navigator 2.0 (page-based routing) with async work - network calls, timers, streams, animations - you’ll eventually hit the dreaded Flutter error: “setState() called after dispose()”. It usually appears as a flaky crash only users can reproduce, often right after a navigation change. This post explains exactly why it happens, how Navigator 2.0 makes it easier to trigger, and how to eliminate it with production-ready patterns you can standardize across your codebase.
Prerequisites
- Flutter 3.22+ and Dart 3.4+ (null safety)
- Familiarity with MaterialApp.router, BuildContext.mounted, and async/await
- Optional: Riverpod 2.x, BLoC 8.x, go_router 14.x
The problem, reproduced
A common scenario: you push a screen, kick off an async request, and the user navigates back before it completes.
import 'package:flutter/material.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
String? _name;
@override
void initState() {
super.initState();
_load(); // Fire-and-forget
}
Future<void> _load() async {
// Simulate network latency
await Future.delayed(const Duration(seconds: 2));
// If user popped this page in the meantime, setState will crash:
setState(() => _name = 'Ada Lovelace');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Center(child: Text(_name ?? 'Loading...')),
);
}
}
Navigate back before 2 seconds elapse and you’ll see:
- setState() called after dispose(): _ProfileScreenState#…
Why? When a route is popped (Navigator 1.0) or your RouterDelegate/go_router updates the pages list (Navigator 2.0), Flutter disposes that State object. Any async callback that tries to call setState() after disposal throws.
Navigator 2.0 makes this more likely because routes can be removed by declarative updates triggered from outside the widget (deep links, auth redirects, URL changes), while your async task is still running.
Root cause in one sentence
You’re calling setState or using BuildContext after an “async gap” (await, then, timer tick, stream event, animation tick) when the widget has already been disposed by navigation.
The baseline fix every async function needs
Always guard UI updates after awaits:
Future<void> _load() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return; // or: if (!context.mounted) return;
setState(() => _name = 'Ada Lovelace');
}
- Use mounted inside State, and context.mounted from anywhere with a BuildContext.
- Add the check after every await where you reference widget state or context.
This simple rule removes most crashes. But in production you also need cancellation, cleanup, and patterns that scale.
Production patterns that scale
1. Cancel work in dispose
Timers, subscriptions, animations, and cancelable futures should be cleaned up in dispose().
-
Timer:
Timer? _timer;
void start() {
_timer = Timer(const Duration(seconds: 5), _onDone);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
} -
StreamSubscription:
late final StreamSubscription<int> _sub;
@override
void initState() {
super.initState();
_sub = stream.listen((value) {
if (!mounted) return;
setState(() {});
});
}
@override
void dispose() {
_sub.cancel();
super.dispose();
} -
AnimationController:
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
} -
Cancelable futures (async package):
# pubspec.yaml
dependencies:
async: ^2.11.0import 'package:async/async.dart';
CancelableOperation<void>? _op;
Future<void> fetch() async {
_op?.cancel();
_op = CancelableOperation.fromFuture(apiCall());
await _op!.valueOrCancellation();
if (!mounted) return;
setState(() {});
}
@override
void dispose() {
_op?.cancel();
super.dispose();
} -
Dio CancelToken:
import 'package:dio/dio.dart';
final _dio = Dio();
final _cancelToken = CancelToken();
Future<void> fetch() async {
final res = await _dio.get('/user', cancelToken: _cancelToken);
if (!mounted) return;
setState(() {});
}
@override
void dispose() {
_cancelToken.cancel('disposed');
super.dispose();
}
2. Don’t await navigation if the callback updates state
Avoid code that awaits a push and then mutates state - because the widget might be gone when the future completes.
Bad:
final result = await context.push('/details'); // go_router or Navigator.push
setState(() => _result = result); // might run after this page is popped
Safer:
context.push('/details').then((result) {
if (!context.mounted) return;
setState(() => _result = result);
});
or explicitly check mounted right after the await:
final result = await context.push('/details');
if (!context.mounted) return;
setState(() => _result = result);
If navigation itself triggers state changes in the current widget, consider not depending on the result at all, or persist the result in shared state (e.g., Riverpod/BLoC), letting the UI rebuild via providers when present.
3. Move long-lived state out of widget trees
Navigator 2.0 encourages declarative routing. Let your page be a pure observer of state, not the owner of it. Managing model state in Riverpod/Bloc/ChangeNotifier outside the route avoids calling setState in disposed pages.
-
Riverpod example:
dependencies:
flutter_riverpod: ^2.5.1import 'package:flutter_riverpod/flutter_riverpod.dart';
final profileProvider =
AsyncNotifierProvider<ProfileController, String?>(ProfileController.new);
class ProfileController extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
// Load on first use
return _loadName();
}
Future<String?> _loadName() async {
await Future.delayed(const Duration(seconds: 2));
return 'Ada Lovelace';
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_loadName);
}
}
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Center(
child: name.when(
data: (v) => Text(v ?? 'Unknown'),
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(profileProvider.notifier).refresh(),
child: const Icon(Icons.refresh),
),
);
}
}
Because the provider outlives the page, its async finishes regardless of navigation. Widgets subscribe/unsubscribe safely; no setState is called on disposed pages.
Riverpod navigation tip: when navigating from providers/listeners, always check context.mounted or use ref.mounted (when interacting with context after an await inside a WidgetRef):
ref.listen(profileProvider, (prev, next) async {
if (next is AsyncData && next.value == null) {
await Future.delayed(const Duration(milliseconds: 200));
if (!ref.mounted) return;
if (!context.mounted) return;
context.push('/createProfile');
}
});
- BLoC tip: Use BlocListener instead of manual subscriptions. Flutter_bloc disposes listeners with the widget, preventing late setStates. Still guard navigation with context.mounted after awaits.
4. Defer UI side effects to a post-frame callback
If you need to trigger navigation/snackbars after build (e.g., based on initState values), use a post-frame callback so the first frame presents the widget before side effects run:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _ensureSomething();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ready')),
);
});
}
Still check mounted - post-frame callbacks can also run after disposal if you scheduled them just before a pop.
5. A reusable SafeAsyncState mixin
Standardize this across your StatefulWidgets to reduce footguns.
import 'dart:async';
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';
mixin SafeAsyncState<T extends StatefulWidget> on State<T> {
final List<CancelableOperation<dynamic>> _ops = [];
final List<StreamSubscription<dynamic>> _subs = [];
void safeSetState(VoidCallback fn) {
if (mounted) setState(fn);
}
Future<S?> track<S>(Future<S> future) {
final op = CancelableOperation<S>.fromFuture(future);
_ops.add(op);
return op.valueOrCancellation().whenComplete(() {
_ops.remove(op);
});
}
S addSub<S>(StreamSubscription<S> sub) {
_subs.add(sub as StreamSubscription<dynamic>);
return sub;
}
@override
void dispose() {
for (final op in List.of(_ops)) {
op.cancel();
}
for (final sub in List.of(_subs)) {
sub.cancel();
}
super.dispose();
}
}
Usage:
class OrdersPage extends StatefulWidget {
const OrdersPage({super.key});
@override
State<OrdersPage> createState() => _OrdersPageState();
}
class _OrdersPageState extends State<OrdersPage> with SafeAsyncState {
List<String> _orders = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final result = await track(fetchOrders()); // cancels on dispose
if (!mounted) return;
safeSetState(() => _orders = result ?? const []);
}
@override
Widget build(BuildContext context) {
// ...
return ListView(children: _orders.map(Text.new).toList());
}
}
6. Navigator 2.0 specifics (RouterDelegate, go_router)
-
RouterDelegate
-
If you own a custom RouterDelegate that listens to app state, ensure you stop notifying listeners after disposal.
-
Example:
class AppRouterDelegate extends RouterDelegate<RoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
final AppState _state;
bool _disposed = false;
AppRouterDelegate(this._state) {
_state.addListener(_onState);
}
void _onState() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
_state.removeListener(_onState);
super.dispose();
}
// build(), setNewRoutePath(), etc.
} -
When async mutations update the pages list, they can instantly dispose pages. Any pending futures in those pages must be guarded with mounted/cancellation.
-
-
go_router
- Never call setState or imperative navigation from inside a builder synchronously to respond to async events. Prefer redirect or GoRouterRefreshStream/ChangeNotifier to trigger re-evaluation.
- If you perform navigation after an await in a widget using context.go/push, always add if (!context.mounted) return; before using context.
- Redirect must be fast and synchronous. Avoid awaiting inside redirect; instead, keep the auth state in a provider and let redirect sync off that state.
-
MaterialApp.router vs MaterialApp
- With MaterialApp.router, route disposal can be triggered by external changes (URL, deep links), not just user gestures. Make every async handler robust against sudden disposal.
7. Prefer builder widgets that manage lifecycles
- FutureBuilder/StreamBuilder prevent you from manually calling setState on async completions. The framework updates the builder when async values change and stops updating after dispose.
- This doesn’t absolve you from canceling sources you created (timers/subscriptions), but it reduces manual setState calls.
FutureBuilder<User>(
future: repo.getUser(),
builder: (context, snap) {
if (!snap.hasData) return const CircularProgressIndicator();
return Text(snap.data!.name);
},
);
Common pitfalls and their fixes
- Starting async in initState with await, then setState without mounted check. Fix: add if (!mounted) return; after the await.
- Using showDialog/ScaffoldMessenger after navigation:
final result = await showDialog(...);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(...); - Timer.periodic updating UI after a pop. Fix: cancel timer in dispose and guard setState.
- Bloc/stream events arriving after a pop. Fix: prefer BlocListener or cancel StreamSubscription in dispose; guard updates with mounted.
- AnimationController/Ticker errors combined with disposed pages. Fix: always dispose controllers.
- Awaiting Navigator.push and mutating state. Fix: check mounted after the await or use then(callback with mounted check).
- Using context after push/pop inside async. Fix: if (!context.mounted) return; before any context usage.
Testing you’re safe
Write a widget test that pops before completion and asserts no exceptions:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('No setState after dispose on early pop', (tester) async {
await tester.pumpWidget(const MaterialApp(home: ProfileScreen()));
await tester.pump(const Duration(milliseconds: 100));
// Pop immediately
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
// Let the async complete
await tester.pump(const Duration(seconds: 3));
expect(tester.takeException(), isNull);
});
}
If your screen doesn’t have a back button, simulate a Router pages update or call Navigator.of(context).pop() within the test.
Performance and correctness notes
- Checking mounted/context.mounted is O(1) and effectively free. Add it liberally after awaits.
- Cancelation avoids wasted work and reduces jank by preventing UI updates for off-screen pages.
- Moving state to providers reduces setState usage and allows Navigator 2.0 to freely add/remove pages without crashing your UI.
- For network calls, prefer cancelable clients (Dio with CancelToken). The http package cannot cancel in-flight requests; you can ignore late results but still consume network and CPU - budget accordingly.
Troubleshooting quick reference
-
Error: setState() called after dispose()
- Cause: you called setState or used context after the widget was removed
- Fix: add mounted/context.mounted check; cancel timers/streams/futures; move state to providers
-
Error when showing SnackBar after a pop
- Fix: if (!context.mounted) return; before ScaffoldMessenger calls
-
go_router redirect loops or side effects in builders
- Fix: keep redirect pure and fast; perform side effects in listeners post-frame with mounted checks
-
Custom RouterDelegate notifying after dispose
- Fix: guard notifyListeners with a _disposed flag; unregister listeners in dispose
A ready-to-use checklist
- Add if (!mounted) return; after every await that leads to setState or uses context.
- Cancel timers, streams, animations, CancelableOperations in dispose.
- Avoid awaiting navigation futures before mutating local state, or check mounted after.
- Prefer provider/BLoC-managed state for long-lived async data.
- Use FutureBuilder/StreamBuilder where practical.
- In go_router/RouterDelegate, keep redirects pure; perform side effects with listeners and mounted checks.
- Cover with a widget test that pops before async completion.
Conclusion
Eliminating “setState() called after dispose()” in Flutter Navigator 2.0 and async flows is about respecting lifecycles in a declarative, page-based world. The reliable recipe is simple but non-negotiable: guard UI mutations with mounted/context.mounted, cancel work in dispose, and move long-lived state out of ephemeral pages. Combined with provider/BLoC patterns and cancelable operations, your app will navigate freely without race-condition crashes - no matter how users move through your routes.
Next steps
- Refactor your async functions to add mounted checks after awaits.
- Introduce the
SafeAsyncStatemixin (or equivalent) in your codebase. - Adopt Riverpod/BLoC for shared state and let pages become passive observers.
- Add widget tests that simulate early pops to prevent regressions.
Related Resource
- Official Documentation: Flutter Navigation & Routing Guide
