Flutter / Asynchronous application boundaries / Lesson 7
Futures and result identity
Separate waiting, lifetime, and freshness so asynchronous completion cannot silently replace newer work.
On this page
Lesson goals
- A Future represents one eventual completion
- Create asynchronous work at a deliberate boundary
- Observe a stable Future
A Future represents one eventual completion
A Future carries either one eventual value or one failure. Awaiting it suspends the asynchronous function’s continuation; it does not freeze the application or automatically move CPU work to another isolate. While the function waits, the user can edit the query, navigate away, or start another operation. Those events change whether the original result is still relevant. Describe an operation as a state transition with an owner, an input identity, and a completion policy. Treating every successful Future as permission to update the screen ignores those intervening events.
Create asynchronous work at a deliberate boundary
A FutureBuilder observes a Future and renders according to its snapshot. It should not be given a freshly created operation on every unrelated build unless restarting is the intended contract. Acquire the Future in the appropriate owner lifecycle or in response to an explicit input change. Then distinguish waiting, error, successful empty data, and successful nonempty data. A spinner for every condition conceals failures and empty results. The builder’s invocation schedule is managed by Flutter, so rendering must remain free of additional requests or other side effects.
Observe a stable Future
The first fragment loads a synthetic label once for the lifetime of its State. The Future field is initialized once; a parent rebuild reuses the same pending or completed operation. The builder supplies explicit waiting and error descriptions and reads successful data only in the appropriate branch. It is a host fragment, not a recorded timing trace. Even an already completed Future can produce a waiting snapshot when supplied to a new FutureBuilder because completion cannot be determined synchronously through this interface. Tests should assert meaningful eventual states rather than a universal count of intermediate builder calls.
late final Future<String> label;
@override void initState() {
super.initState();
label = Future<String>.value('Maps');
}
Widget content() => FutureBuilder<String>(
future: label,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const Text('Loading');
if (snapshot.hasError) return const Text('Could not load');
return Text(snapshot.data ?? 'No label');
},
);
The eventual successful label is Maps.
The surrounding State and imports are omitted. Future.value supplies local synthetic data without network access.
Guard freshness as well as lifetime
The second fragment increments a generation for every search attempt. A completion commits only if the State remains mounted and its ticket still matches the newest generation. This is a latest-result policy, not cancellation: earlier work can still consume resources. If the underlying API supports cancellation, combine cancellation with identity checks. A timeout likewise limits waiting but need not stop the original work. Define whether refresh failures retain previous useful results or replace them with an error, and apply the same identity rule to error completion as to successful completion.
int generation = 0;
List<String> results = [];
String? failure;
Future<void> search(String query) async {
final ticket = ++generation;
try {
final next = await repository.search(query);
if (!mounted || ticket != generation) return;
setState(() { results = next; failure = null; });
} catch (_) {
if (!mounted || ticket != generation) return;
setState(() { failure = 'Search failed'; });
}
}
Only the newest search attempt may replace results or failure.
repository is an injected application dependency. The example deliberately does not issue a real request or claim transport cancellation.
Catch errors where recovery can be decided
Catch an error near the boundary that can translate it into a useful application outcome. A broad catch that silently returns an empty list makes a failed request indistinguishable from a real empty search. Preserve diagnostic context through the application’s reviewed logging policy while exposing a safe message to the user. Do not log credentials or complete sensitive payloads to investigate a race. For an operation that changes remote state, uncertain completion may require reconciliation rather than blind retry, because the server might already have accepted the command.
Test completion order explicitly
Use a fake repository whose pending responses can be completed in a chosen order. Start search A, start search B, complete B, then complete A and verify B remains visible. Repeat with A failing after B succeeds. Also dispose the owner before completion and verify no UI commit occurs. These tests establish lifetime and freshness contracts without real timing delays or a live server. A test that sleeps for an arbitrary number of milliseconds can pass by luck while never exercising the problematic ordering.
Common errors and fixes
An old search overwrites a newer result
- Why it happens
- Completion order was mistaken for request order.
- How to fix it
- Check request identity before both success and error commits.
- Prevent it next time
- Control completion order with fakes.
A rebuild repeats the load
- Why it happens
- The Future is recreated in build.
- How to fix it
- Store or refresh the operation at an explicit owner boundary.
- Prevent it next time
- Rebuild the parent during a pending request.
A failed request appears as no matches
- Why it happens
- Error handling replaced failure with an empty collection.
- How to fix it
- Represent error and successful emptiness separately.
- Prevent it next time
- Assert both failure and empty-result UI.
Check your understanding
1. Does mounted prevent stale results while the same screen remains open?
Show worked solution
No. It verifies that the State has not been disposed. Query A and query B can belong to the same mounted owner. Add an input or generation identity and only commit the result that still matches the current operation policy.
2. How should a stale failure affect a newer successful search?
Show worked solution
It should not replace the newer success under a latest-result policy. Apply the ticket check in the error branch as well as the success branch. Keep diagnostic evidence separately if needed, without presenting that obsolete failure as the active screen state.
Key takeaways
- A Future represents one eventual completion
- Test completion order explicitly