Core
Navigation
useNavigate pushes, replaces, and pops screens; useParams, usePathname, and useStep read where you are and move within one screen.
useNavigate
const navigate = useNavigate();navigate.push("/posts/:slug", { slug: "hello" });navigate.replace("/login");navigate.pop(); // back one screennavigate.pop({ skip: 2 }); // back two screens, one transitionnavigate.pop({ until: "/posts/:slug" }); // back to the nearest matchAll three return a promise, so you can await a move.
await navigate.push("/posts/:slug", { slug: "hello" });// The post screen is now on top.A call made while a transition is running is ignored, not queued, so only the first tap counts.
Reaching past the top screen
skip counts screens and until takes a route pattern. Either way one transition plays, and the screens in between never appear.
Choosing which Router moves
useNavigate moves the nearest Router that encloses the component. To open a screen over the whole app from inside a nested Router, pass router. The move then runs on that Router's history, transition and swipe gestures.
// Inside the nested "region" Router.const navigate = useNavigate();// Stays in the region Slot.navigate.push("/region/people");// Takes over the whole screen, on the app Router.navigate.push("/members/:id", { id }, { router: "app" });Every target is looked up from where the hook is called, moving outward through the Routers that enclose it. A Router beside yours, in another branch, is never reached, even by name.
Set a default on the hook. A router passed to a single call overrides it.
const regionNavigate = useNavigate();const appNavigate = useNavigate({ router: "app" });const parentNavigate = useNavigate({ router: "parent" });appNavigate.push("/members/:id", { id });regionNavigate.replace("/region/people", undefined, { transitionName: "tabForward" });parentNavigate.pop({ transitionName: "cupertino" });A path can type-check even if the target Router does not declare it, and that Router's area then transitions to an empty screen. Development mode reports it.
Reading the current route
useParams returns the screen's params, path and query merged, typed by RegisterRoute.
function Post() { const { slug } = useParams<"/posts/:slug">(); return <h1>{slug}</h1>;}UI outside any Screen, like a header beside a Slot, reads the nearest Router's current path with usePathname.
function Header() { const pathname = usePathname(); return <nav data-active={pathname}>...</nav>;}Steps within one screen
useStep changes params without leaving the screen, like a sign-up form going name, email, password. Each step is a history entry, so Back returns to the previous step.
function Onboarding() { const { step = "name" } = useParams<"/onboarding">(); const stepper = useStep<"/onboarding">(); if (step === "name") { return <button onClick={() => stepper.pushStep({ step: "email" })}>Next</button>; } return <button onClick={() => stepper.popStep()}>Back</button>;}Edge casesDetails
Promises and timing
- The promise settles when the navigation task completes, so after
awaitthe move has finished. Jumping several screens still plays one transition, not one per screen. - A call ignored because the Router is mid-transition resolves at once without navigating. Rapid taps never play the move twice.
skip and until
skipanduntilare mutually exclusive. If you pass both,untilwins.untilreaches the nearest screen matching that declared route.- An unmatched
untildoes nothing forpopandreplace, and is a plain push forpush.
Router targets
- Targets are always resolved from where the hook was called, and only walk the current Router and the Routers around it, never a sibling.
- A bare string is read as a keyword first and as a Router name second. If a Router is named after a keyword, use the object forms:
{ router: { name: "parent" } }picks the Router namedparent,{ router: { scope: "parent" } }the enclosing one. nearest-ownerneeds a path. Apophas none, so it falls back tocurrentwith a development warning. To pop a different Router, target it by name.nearest-ownermatches the actual pathname, so a Router declaring/files/*splathandles/files/a.
When the route is not there
RegisterRoute is one global registry, so a path can type-check while the target Router does not declare it. The entry then has no Route to mount, which is the broken half-transition you see when a nested Router is asked to open a full-screen route.
- You named a Router that does not declare the path: development error.
- You named a Router that does not enclose the call, or
parentat the outermost Router: development error. - You used
nearest-ownerand no enclosing Router declares the path: development error. - You left the target implicit and the nearest Router does not declare the path: development warning, behavior unchanged.
- Pass
strictRoutesto thatRouterto make the last case an error too, or userouter: "nearest-owner"to let flemo pick the Router that declares the path.
All of these are development-only. Production never throws over a navigation: a target that cannot be found does nothing, and a missing route behaves as described above.
Params and steps
- Params come from the navigation that mounted the screen. Params the path does not use travel in the query string.
- A
useSteppush updatesuseParamsin place without stacking a new screen. useStep<"/route">()reuses a registered route's params. Outside aScreen, pass the param type directly, likeuseStep<{ menu: boolean }>().- Called outside a
Screen, there is no route. The step keeps the current pathname, appends its params as a query, and reports them throughstepafter mount. Inside aScreen,stepstaysnull, so readuseParamsinstead. - A step lets browser Back close a sheet or a menu instead of leaving the page.
usePathnamereports the navigation's destination, so during a pop it already returns the path being returned to.
function MenuButton() { const { step, pushStep, popStep } = useStep<{ menu: boolean }>(); const open = step?.menu === true; return <button onClick={() => (open ? popStep() : pushStep({ menu: true }))}>Menu</button>;}