A Flutter checkout awaits Navigator.push for an address picker, yet sometimes receives null although the user saved a new address; what causes this and how do you fix it?
answer
- who completes the Future you await
- replacement completes it early
- removed routes complete with null
- push the form on top, then forward
- typed helper: Future<Address?> pick
basics
~20 sThe awaited Future belongs to the picker route, so pushReplacement or a remove-until call that ends the picker early completes it with null. Push the form on top of the picker, await it, then pop the picker with the result.
solid answer
~40 sThe Future checkout awaits is tied to the picker route, not to the flow. The most common cause is that the picker replaced itself with the add-address form via `pushReplacement`: that completes checkout's Future immediately with the `result:` argument, `null` by default, and the form's later `pop(address)` goes to nobody. The same happens if a `pushAndRemoveUntil` or `popUntil` removes the picker, since removed routes complete with their default result. Legitimate `null`s come from back navigation. Fix it by pushing the form on top of the picker, awaiting its result, and then calling `Navigator.pop(context, created)` on the picker after a `context.mounted` check. Then harden the contract: a static `Future<Address?> pick(BuildContext)` helper, typed routes so a wrongly typed pop fails loudly in debug, and tests that assert the value checkout receives for save and for back.
code
dart · 54 linesimport 'package:flutter/material.dart';
class Address {
const Address(this.label);
final String label;
}
class AddressPickerScreen extends StatelessWidget {
const AddressPickerScreen({super.key});
/// The only way callers open the picker: typed route, typed result.
static Future<Address?> pick(BuildContext context) => Navigator.push<Address>(
context,
MaterialPageRoute<Address>(builder: (context) => const AddressPickerScreen()),
);
Future<void> _addNew(BuildContext context) async {
// Push the form ON TOP of the picker, then forward its result.
// pushReplacement would complete the caller's Future with null right away.
final Address? created = await Navigator.push<Address>(
context,
MaterialPageRoute<Address>(builder: (context) => const NewAddressScreen()),
);
if (created != null && context.mounted) Navigator.pop(context, created);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Deliver to')),
body: ListTile(
leading: const Icon(Icons.add),
title: const Text('Add a new address'),
onTap: () => _addNew(context),
),
);
}
}
class NewAddressScreen extends StatelessWidget {
const NewAddressScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: FilledButton(
onPressed: () => Navigator.pop(context, const Address('12 Harbour St')),
child: const Text('Save'),
),
),
);
}
}go deeper
Recall that the value you await comes from the pushed route's pop, and that back navigation gives null.
Explain which navigation calls complete a route's Future early, especially pushReplacement's result argument and removal by remove-until calls.
Diagnose from timing and logs, fix by stacking and forwarding results, and harden the contract with typed helpers and tests for save and back.
Decide when chained route results should give way to a shared draft-order state that navigation merely moves between.
## The model to reason from `Navigator.push` returns a `Future<T?>` owned by **the route that was pushed**. That Future completes exactly once, when that route leaves the stack, however it leaves. Checkout awaiting `push(addressPicker)` is waiting for **the picker route**, not for "the user finished choosing an address". Every lost-result bug in this flow comes from the picker leaving the stack earlier, or differently, than the author assumed. ## Causes, from most to least common | Cause | What checkout receives | When | |---|---|---| | Picker called `pushReplacement` to show the add-address form | `result:` of that call, `null` by default | at replacement, before the user saves | | Something called `pushAndRemoveUntil` or `popUntil` past the picker | the picker's default result, `null` | when the picker is removed | | User pressed back, or the app-bar back arrow | `null` | by design: cancelled | | Picker popped a value of the wrong type | debug: a `FlutterError`; the value never arrives as an `Address` | at pop | | Result popped from a dialog's context | the dialog closes; the picker stays | the picker later pops with nothing | The first row is the classic. The author thinks "replace the picker with the form, the form returns the address". But `pushReplacement` completes the **replaced** route's Future at the moment of replacement, using its `result:` argument. The new form route has its own Future, which nobody is awaiting, so its `pop(address)` is discarded. ## Reproducing it 1. Open checkout, tap "Choose address". 2. In the picker, tap "Add new", which runs `Navigator.pushReplacement(context, MaterialPageRoute<Address>(...))`. 3. Checkout's `await` resumes at once with `null` and shows "No address", while the form is still on screen. 4. Save the form: it pops `Address(...)` back to checkout's screen, but no code receives it. A log statement right after checkout's `await` shows the tell-tale timing: it fires when the form **opens**, not when it closes. ## The fix Keep the picker on the stack while the form is open, and forward the result: ```dart Future<void> _addNew(BuildContext context) async { final Address? created = await Navigator.push<Address>( context, MaterialPageRoute<Address>(builder: (context) => const NewAddressScreen()), ); if (created != null && context.mounted) Navigator.pop(context, created); } ``` Now the form's result completes the picker's inner await, and the picker pops with it, completing checkout's await. Back from the form returns to the picker, which is also the better user experience. If replacement really is wanted, pass the value explicitly (`pushReplacement(..., result: someValue)`), knowing it is delivered immediately, not after the new route finishes. ## Hardening the contract - **One typed entry point.** `static Future<Address?> pick(BuildContext context)` on the picker hides the route construction; callers cannot forget the type argument. - **Typed routes everywhere.** With `MaterialPageRoute<Address>`, popping a `String` throws a `FlutterError` in debug builds ("A request was made to pop a route with a result of type String, but the route expected a value of type Address"), turning a silent `null` into a loud failure during development. - **Treat `null` as cancel.** Checkout keeps its previous address on `null` rather than clearing it. - **Check `mounted`** after every awaited navigation before calling `setState` or using the context. - **Widget tests for both outcomes.** Tap an address and assert checkout shows it; press back and assert checkout keeps its previous value. ## A checklist when a result goes missing 1. Log right after the caller's `await`: does it fire when the child route **opens** (replacement or removal) or when it **closes**? 2. Search the flow for `pushReplacement`, `pushAndRemoveUntil` and `popUntil` between push and the expected pop. 3. Confirm every `pop` that carries a value runs with the picker's context, not a dialog's or a nested navigator's. 4. Run in debug to surface result-type mismatches as errors. ## When results are the wrong tool If several screens contribute to one order (address, delivery slot, payment), passing values back through a chain of route results becomes fragile. Holding the draft order in a shared state object that each screen updates, and using navigation only to move between screens, removes the dependency on which route pops what.
- How would you confirm in a widget test that checkout receives the new address?Pump checkout, tap the choose-address tile, tap "Add a new address", tap Save, then `pumpAndSettle` and expect the saved label on checkout. A second test presses back from the picker and expects the previous address to remain, covering the `null` path.
- Why does the fixed picker check context.mounted before popping?The picker awaited the form. While the form was open, the picker could have been removed, for example by a sign-out that reset the stack. Using its context after that would target a navigator the picker no longer belongs to, so it checks `context.mounted` first.
saying these in an interview costs you the question
- Believes pushReplacement forwards the new route's result to the old awaiter
- Blames the null on Dart futures being unreliable
- Fixes it with a global variable holding the last saved address
- Clears checkout's address whenever the picker returns null
- Thinks removed routes leave their awaiters pending forever