Skip to main content

Navigation

utopia_hooks is navigation-agnostic. Nothing in the package pushes a route, and it exports no router or Navigator helper. So navigation is not a hook feature to learn - it is a convention about where navigation lives relative to the Screen / State / View split. The examples below use plain Flutter Navigator; the same shape holds unchanged with go_router, auto_route, or any Navigator 2.0 setup.

The core rule: navigation is a callback​

Navigation flows Screen -> State -> View as typed callbacks. The Screen closes over BuildContext in build(), builds void Function() / Future<T?> Function() callbacks, and passes them into the state hook. The hook stores them as State fields. The View calls them. The state hook never sees a BuildContext, a router, or a NavigatorKey.

// DON'T - the hook navigates itself, with a route as a magic string
ItemDetailsScreenState useItemDetailsScreenState({required ItemId itemId}) {
final navigatorKey = useProvided<NavigatorKey>(); // never in a screen hook
void onEditPressed() =>
navigatorKey.currentState?.pushNamed('/edit-item', arguments: itemId); // untestable, string drift
// ...
}

// DO - the Screen owns context and builds the callback; the hook receives it
class ItemDetailsScreen extends HookWidget {
const ItemDetailsScreen({required this.args});

final ItemDetailsArgs args;


Widget build(BuildContext context) {
final state = useItemDetailsScreenState(
itemId: args.itemId,
navigateToEdit: () => EditItemScreen.navigate(context, itemId: args.itemId),
);
return ItemDetailsScreenView(state: state);
}
}

Passing one BuildContext parameter instead of three callbacks looks cheaper, but it lets the hook call Navigator.of(context) and Dialog.show(context) internally - exactly the wiring the Screen owns - and it makes the hook impossible to unit-test without a widget tree. The callbacks are the hook's contract. This rule never relaxes; everything below builds on it.

The Screen owns its route​

Give each Screen its own route identity as statics, so the route string exists in exactly one place and callers go through a typed navigate:

class EditItemScreen extends HookWidget {
const EditItemScreen._();

static const route = 'edit-item';

static Route<void> buildRoute(RouteSettings settings) =>
MaterialPageRoute(settings: settings, builder: (_) => const EditItemScreen._());

static Future<void> navigate(BuildContext context, {required ItemId itemId}) =>
Navigator.of(context).pushNamed(route, arguments: EditItemArgs(itemId: itemId));


Widget build(BuildContext context) {
final args = ModalRoute.of(context)!.settings.arguments! as EditItemArgs;
// ...
}
}

route is the only place the string lives; buildRoute passes settings through so the route keeps its name and arguments; the private constructor keeps the screen reachable only via routing; and the argument cast at the top of build() is intended to fail fast on a mistyped payload. A central table maps names to factories and feeds onGenerateRoute, so adding a screen is two edits - the statics, and one row in the table.

When a screen pops a result, type the route factory (MaterialPageRoute<Color>) so a typed pushNamed<Color> does not fail its cast on a Route<dynamic>.

With another router the discipline is identical - the Screen still owns a typed navigate, the hook still receives callbacks. Only the body of the callback changes (context.push(...) instead of pushNamed).

Classify each navigation by its trigger before wiring it:

  • A direct consequence of an action only this screen performs (save, then go back): navigate in the action itself - afterSubmit: (_) => navigateBack() on the submit state.
  • A reaction to state that can change from elsewhere (login status, an entitlement flag, registration completing): a useEffect in the state hook, keyed on the global flag, calling a Screen-built callback.
LoginScreenState useLoginScreenState({required void Function() navigateToHome}) {
final auth = useProvided<AuthState>();

// Fires whenever the flag flips, regardless of which flow set it -
// this form, a social-login callback, a deep-link token, session restore.
useEffect(() {
if (auth.isLoggedIn) navigateToHome();
return null;
}, [auth.isLoggedIn]);
// ...
}

The fragile alternative - await auth.login(...); navigateToHome(); inside the submit closure - fires only for that one code path; every other flow that flips isLoggedIn silently fails to navigate.

For an app-wide redirect that maps global state to a target route (post-login, sign-out, forced update), generalize this into one shell-level hook keyed with useValueChanged, so each distinct target navigates exactly once. Note that pushNamed-style futures complete when the pushed route is popped, not when the transition ends - never await them to sequence a redirect.

Hosting a Screen in a sheet or dialog​

A bottom sheet or dialog that hosts a full Screen / State / View triple is just another Screen whose callbacks happen to pop. The widget plays the Screen role and builds Navigator.pop-based callbacks:

class OnboardingSheet extends HookWidget {
const OnboardingSheet._();

static Future<bool?> show(BuildContext context) => showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => const OnboardingSheet._(),
);


Widget build(BuildContext context) {
final state = useOnboardingSheetState(
finish: () => Navigator.of(context).pop(true), // completed
skip: () => Navigator.of(context).pop(false), // explicit decline
);
return OnboardingSheetView(state: state);
}
}

// Caller: true = completed, false = declined, null = dismissed (tap outside / back)
final completed = await OnboardingSheet.show(context);

The three-valued result (true / false / null) is the convention worth keeping: callers that do not care about the distinction just check == true. The state hook receives finish / skip like any other navigation and never knows it lives in a sheet.

A NavigatorKey (GlobalKey<NavigatorState>, passed to MaterialApp.navigatorKey) is the rare handle that may be read outside a Screen - but only by the app shell: hooks living above the route stack, with no screen BuildContext of their own. In practice that is the app-level redirect and a global error dialog. A screen state hook never reads it (useProvided<NavigatorKey>() in a screen hook is the canonical anti-pattern); a screen always has a better tool - its own BuildContext, closed over by the Screen into a typed callback.

Patterns and pitfalls​

  • BuildContext smuggled in via a setContext setter or a context field on the State - the hook-takes-context anti-pattern in disguise, plus a stale-context bug after rebinds. Build the callback in the Screen.
  • The View calling Navigator directly - the View calls state.onClosePressed; the Screen-built callback pops.
  • A magic route string at a call site - the string lives once in Screen.route; callers use Screen.navigate(...).
  • Navigation buried in a submit's run() when the trigger is global state - it fires for that one path only. Key a useEffect on the global flag instead.
note

NestedNavigator and ScopedNavigatorState exist in the optional utopia_arch package but are both deprecated ("Use Navigation 2 instead"). They are not part of utopia_hooks and not recommended - prefer the callback discipline above over either.

See also​