Core

Navigation

useNavigate pushes, replaces, and pops screens; useParams, usePathname, and useStep read where you are and move within one screen.

2 min read

Places

Saved for later

Idlecupertino
Duration700ms
LivePush the detail screen, then go back with the Back button or a drag from the left edge.

useNavigate

ts
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 match

All three return a promise, so you can await a move.

ts
await navigate.push("/posts/:slug", { slug: "hello" });// The post screen is now on top.
OptionWhat it does
transitionNameOverrides the default transition (on pop, the back animation)
skip / untilReach a screen below the top in one transition
routerRun on a different Router

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.

MethodAt the screen it reachesDefault `skip`
popGoes back to it1
replaceReplaces it and everything above0
pushKeeps it and stacks on top0

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.

tsx
// 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" });
`router`Which Router it picks
omitted / currentThe nearest Router that encloses the call (default)
parentThe Router that encloses the current one
rootThe outermost Router
"app" (a name)The enclosing Router with name="app"
nearest-ownerThe first Router, starting from the current one and moving outward, that declares the path you navigate to

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.

ts
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.

tsx
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.

tsx
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.

tsx
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>;}
ReturnsWhat it does
pushStep(params)New history entry, same route
replaceStep(params)Replace the current entry
popStep()Back one step
stepCurrent params, for UI outside a Screen
Edge casesDetails

Promises and timing

  • The promise settles when the navigation task completes, so after await the 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

  • skip and until are mutually exclusive. If you pass both, until wins.
  • until reaches the nearest screen matching that declared route.
  • An unmatched until does nothing for pop and replace, and is a plain push for push.

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 named parent, { router: { scope: "parent" } } the enclosing one.
  • nearest-owner needs a path. A pop has none, so it falls back to current with a development warning. To pop a different Router, target it by name.
  • nearest-owner matches the actual pathname, so a Router declaring /files/*splat handles /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 parent at the outermost Router: development error.
  • You used nearest-owner and 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 strictRoutes to that Router to make the last case an error too, or use router: "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 useStep push updates useParams in place without stacking a new screen.
  • useStep<"/route">() reuses a registered route's params. Outside a Screen, pass the param type directly, like useStep<{ 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 through step after mount. Inside a Screen, step stays null, so read useParams instead.
  • A step lets browser Back close a sheet or a menu instead of leaving the page.
  • usePathname reports the navigation's destination, so during a pop it already returns the path being returned to.
tsx
function MenuButton() {  const { step, pushStep, popStep } = useStep<{ menu: boolean }>();  const open = step?.menu === true;​  return <button onClick={() => (open ? popStep() : pushStep({ menu: true }))}>Menu</button>;}
Edit on GitHub