Prevent Stale Asynchronous Results from Overwriting UI State
Master request identity sequencing, cancellation tokens, and mounted checks to prevent out-of-order network responses from corrupting newer screens.
1. The Core Challenge & Problem
When users perform rapid searches, switch tabs, or tap filter pills in quick succession, asynchronous HTTP responses can complete in a different order than they were dispatched. A slower response from the initial query ("fl") may resolve AFTER a faster response for the refined query ("flutter"). Without explicit request sequencing or cancellation, the older result silently overwrites the newer state, displaying incorrect data. Furthermore, calling setState() after a user has popped the route triggers "setState() called after dispose()".
2. Architectural Principles & Resolution
There are three complementary defenses against stale asynchronous mutations in Flutter: 1. Monotonic Request ID (Sequence Token): Maintain an internal integer incremented on every new user action. Before updating state, compare the dispatch token with the active token: if (requestId != _activeRequestId) return;. If stale, discard the result immediately. 2. Cancellation (CancelToken / StreamSubscription): If using HTTP clients like Dio or Stream controllers, cancel the previous in-flight request as soon as a new search is initiated. 3. Stateful Lifecycle Guard: Always verify if (!mounted) return; before calling setState().
3. Complete Flutter Code
main.dart (Flutter 3.x+ ready)import 'dart:async';
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SearchRaceConditionDemo()));
class SearchRaceConditionDemo extends StatefulWidget {
const SearchRaceConditionDemo({super.key});
@override
State<SearchRaceConditionDemo> createState() => _SearchRaceConditionDemoState();
}
class _SearchRaceConditionDemoState extends State<SearchRaceConditionDemo> {
int _activeRequestId = 0;
String _currentQuery = '';
List<String> _results = [];
bool _isLoading = false;
// Simulated backend API with unpredictable artificial latency
Future<List<String>> _mockApiSearch(String query, int simulatedLatencyMs) async {
await Future.delayed(Duration(milliseconds: simulatedLatencyMs));
return List.generate(3, (i) => 'Result ${i + 1} for "$query"');
}
void _onSearchChanged(String query) async {
// 1. Increment monotonic request ID
final int thisRequestId = ++_activeRequestId;
setState(() {
_currentQuery = query;
_isLoading = true;
});
// Simulate edge case: First query takes 800ms, subsequent query takes only 200ms
final latency = query == 'fl' ? 800 : 200;
final data = await _mockApiSearch(query, latency);
// 2. CRITICAL: Check both widget mounted status and monotonic request identity
if (!mounted) return;
if (thisRequestId != _activeRequestId) {
// Stale response! Discard quietly without corrupting active UI state
return;
}
setState(() {
_results = data;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('T01.03: Stale Async Prevention')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
onChanged: _onSearchChanged,
decoration: const InputDecoration(
labelText: 'Search (Type "fl" then quickly "flutter")',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
if (_isLoading) const LinearProgressIndicator(),
const SizedBox(height: 8),
Text('Active Request ID: $_activeRequestId', style: const TextStyle(fontWeight: FontWeight.bold)),
const Divider(),
Expanded(
child: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, i) => ListTile(title: Text(_results[i])),
),
),
],
),
),
);
}
} 4. Expected Visual & Behavioral Result
Typing "fl" followed immediately by "flutter" ensures that when the delayed 800ms response for "fl" finally resolves, its request token is rejected as stale (_activeRequestId mismatch). The screen reliably displays the correct results for "flutter".
5. Common Pitfalls & Traps
Pitfall #1: Only checking if (mounted) without monotonic request tokens
Pitfall #2: Calling setState directly inside an unawaited Future callback
Pitfall #3: Relying solely on UI debouncing to fix race conditions