Fixing Dart Async/Await Pitfalls That Break Flutter UI State
Your button press fires, the loading spinner appears, and then nothing updates. Or worse: the UI updates but with data from the previous request. Dart's async/await model is clean on the surface, but Flutter's widget lifecycle adds several traps that catch even experienced developers off guard.
This article covers the specific mistakes that break UI state in Flutter apps and shows you exactly how to fix them.
What you'll learn
- Why calling
setStateafter a widget is disposed crashes your app and how to guard against it - How unawaited futures silently swallow errors and leave your UI in a wrong state
- Why
FutureBuilderrebuilds more than you expect and what to do about it - How to cancel async operations cleanly when a widget leaves the tree
- Common
asyncmistakes insideinitStateand how to handle them safely
Prerequisites
You should be comfortable with Flutter's StatefulWidget lifecycle and have written at least a few screens that load data from an API or local storage. Code examples use Dart 3 syntax, but the patterns apply to any recent Flutter version.
The setState After Dispose Crash
This is the most common async bug in Flutter. You kick off a network request, the user navigates away before it finishes, and when the future resolves your code calls setState on a widget that no longer exists in the tree.
The error message looks like this:
setState() called after dispose()
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree.The fix is simple: check mounted before calling setState.
Future<void> _loadData() async {
final result = await fetchUserProfile();
if (!mounted) return; // guard here
setState(() {
_profile = result;
});
}mounted is a property on State that returns false once the widget has been removed from the tree. Always check it before any setState call that follows an await.
Unawaited Futures Hide Errors
When you call an async function without await and without attaching a .catchError handler, any exception it throws disappears silently. Your UI just stays in whatever state it was in, with no error message and no log entry in release mode.
// Bad β errors vanish silently
void _onButtonPressed() {
saveToDatabase(formData); // no await, no error handling
}
// Better
void _onButtonPressed() {
saveToDatabase(formData).catchError((e) {
// show a snackbar or update error state
_showError(e.toString());
});
}
// Best β use async properly
Future<void> _onButtonPressed() async {
try {
await saveToDatabase(formData);
} catch (e) {
_showError(e.toString());
}
}The Dart linter has a rule called unawaited_futures that flags these. Enable it in your analysis_options.yaml so the problem surfaces at compile time rather than in production.
linter:
rules:
- unawaited_futuresIf you genuinely want to fire-and-forget (rare, but valid), wrap the call with unawaited() from dart:async to make your intent explicit and silence the lint warning.
async in initState β The Wrong Pattern
initState cannot be async. If you try to make it one, Flutter will not await it, and you'll load data after the first build runs with empty state. This is a common source of flickering or missing data on first render.
// Wrong β initState is not actually awaited by Flutter
@override
void initState() {
super.initState();
await _loadData(); // compile error, and wrong conceptually
}
// Correct β call async work in a regular method
@override
void initState() {
super.initState();
_loadData(); // fire and handle internally
}
Future<void> _loadData() async {
try {
final data = await fetchData();
if (!mounted) return;
setState(() => _data = data);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}This pattern keeps initState synchronous as Flutter expects, while still handling errors and guarding against the disposed-widget crash.
FutureBuilder Rebuilding on Every Render
FutureBuilder is convenient, but it has a sharp edge: if you pass a future that is created inline β inside the build method β a new Future is created on every rebuild. This means your network request fires again every time anything in the widget tree causes a repaint.
// Bad β a new Future is created on every build call
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: fetchProducts(), // called fresh every rebuild
builder: (context, snapshot) {
// ...
},
);
}
// Good β store the Future in state, created once
late final Future<List<Product>> _productsFuture;
@override
void initState() {
super.initState();
_productsFuture = fetchProducts();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _productsFuture, // stable reference
builder: (context, snapshot) {
// ...
},
);
}By assigning the future once in initState and handing the stored reference to FutureBuilder, you guarantee the request runs exactly once per widget lifecycle.
Race Conditions with Multiple Async Calls
Imagine a search field that fires a network request on every keystroke. If the user types quickly, request 1 may resolve after request 3, and your UI ends up showing results for an earlier query. This is a classic race condition.
The cleanest Flutter-native fix is to track a sequence counter and ignore results from stale requests.
int _requestId = 0;
Future<void> _search(String query) async {
final thisRequest = ++_requestId;
final results = await searchApi(query);
if (thisRequest != _requestId) return; // stale, discard
if (!mounted) return;
setState(() => _results = results);
}Every time _search is called it increments the counter. When a result arrives, it only applies if the counter still matches β meaning no newer request has been issued since.
For more complex scenarios, consider using a CancelableOperation from the async package, or move the logic into a state management solution like Riverpod or BLoC that handles debouncing and cancellation for you.
Not Cancelling Async Work on Dispose
Even with the mounted guard, long-running async operations keep executing after a widget is disposed. That wastes resources and can cause subtle bugs when shared state is involved. The right approach is to cancel the work entirely.
For Stream-based work, always cancel your StreamSubscription in dispose.
StreamSubscription<LocationData>? _locationSub;
@override
void initState() {
super.initState();
_locationSub = locationStream.listen((data) {
if (!mounted) return;
setState(() => _location = data);
});
}
@override
void dispose() {
_locationSub?.cancel();
super.dispose();
}
For HTTP requests, look at whether your HTTP client supports cancellation tokens. The dio package, for example, accepts a CancelToken that you can cancel in dispose. Plain http package requests cannot be cancelled mid-flight, which is one reason many teams switch to dio for Flutter apps.
Common Pitfalls at a Glance
| Mistake | Symptom | Fix |
|---|---|---|
No mounted check | setState after dispose crash | Check mounted before setState |
| Unawaited future | Errors silently dropped | Await or attach catchError |
| async initState | Stale data on first render | Use a separate async method |
| Inline future in build | Request fires on every rebuild | Store future in initState |
| No request deduplication | Race condition, stale results shown | Use a sequence counter or cancellation |
| No stream cancel | Memory leaks, spurious updates | Cancel subscription in dispose |
Using async Safely with State Management
Most of the patterns above apply regardless of your state management choice. But if you use Riverpod, BLoC, or similar libraries, many of these concerns are handled for you. Riverpod's AsyncNotifier, for instance, tracks its own lifecycle and will not update state after it has been disposed. BLoC's emit is also safe to call β it checks whether the bloc is closed before dispatching an event.
That does not mean you can skip error handling. It means you can stop worrying about the mounted guard and focus on building the logic layer cleanly. If you are managing async state directly in StatefulWidget, the manual guards above are essential.
Next Steps
- Add the
unawaited_futuresandcancel_subscriptionslint rules to youranalysis_options.yamltoday and fix any warnings they surface. - Audit every
FutureBuilderin your app and confirm its future is stored inState, not created insidebuild. - Add a
mountedcheck after everyawaitthat precedes asetStatecall. - For screens with search or live queries, introduce a sequence counter or switch to a solution like Riverpod's
AsyncNotifierto handle cancellation automatically. - Review any
StreamSubscriptionin your widgets and confirm each one is cancelled indispose.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!