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).
Navigating in reaction to state
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
useEffectin 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.
NavigatorKey - the one sanctioned global
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
BuildContextsmuggled in via asetContextsetter 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
Navigatordirectly - the View callsstate.onClosePressed; the Screen-built callback pops. - A magic route string at a call site - the string lives once in
Screen.route; callers useScreen.navigate(...). - Navigation buried in a submit's
run()when the trigger is global state - it fires for that one path only. Key auseEffecton the global flag instead.
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
- Screen / State / View - the callback rule this page builds on
- Global state - the
useProvidedstate that reactive navigation keys off - useValueChanged - fires a callback once per distinct value, behind the app-level redirect
- Multi-page shells - switching between sibling pages without leaving the screen