Routing hooks

useLocation, useParams, useNavigate, usePrefetch, useLoaderData — navigating + reading URL state.

The routing hooks from @voltro/web. They read URL state, trigger navigation, and access loader data.

useLocation()

The current pathname (just the path; query string is separate).

import { useLocation } from '@voltro/web'

const pathname = useLocation()
// '/docs/intro/getting-started'

Updates on every navigation. Use for:

  • Active-link styling
  • "Did the URL change?" effect dependencies
  • Conditional rendering based on path
const Nav = () => {
  const pathname = useLocation()
  return (
    <ul>
      <li><Link to="/" className={pathname === '/' ? 'active' : ''}>Home</Link></li>
      <li><Link to="/about" className={pathname === '/about' ? 'active' : ''}>About</Link></li>
    </ul>
  )
}

For "active if URL starts with prefix":

className={pathname.startsWith('/dashboard') ? 'active' : ''}

useParams<T>()

URL params from [name] segments. Typed via the generic.

// src/pages/users/[id]/page.tsx
import { useParams } from '@voltro/web'

const { id } = useParams<{ id: string }>()

For catch-all queries:

// src/pages/docs/[...slug]/page.tsx
const { slug } = useParams<{ slug: string }>()
// /docs/intro/getting-started → slug = "intro/getting-started"

For multi-segment dynamic paths:

// src/pages/orgs/[orgId]/projects/[projectId]/page.tsx
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()

Params are always strings — convert numbers explicitly:

const id = Number(params.id)
if (Number.isNaN(id)) throw new NotFoundError()

useNavigate()

Programmatic navigation.

import { useNavigate } from '@voltro/web'

const SignOutButton = () => {
  const navigate = useNavigate()
  const onSignOut = async () => {
    await fetch('/auth/signout', { method: 'POST' })
    navigate('/login')
  }
  return <button onClick={onSignOut}>Sign out</button>
}

Returns a function (to: string, options?) => void.

Option Notes
replace: true Replace the history entry (no back-button entry).
scroll: false Don't scroll to top after navigation.
transition: true / false Run (or suppress) this navigation's swap through document.startViewTransition, overriding the app-wide router.viewTransitions default. <Link transition> is the declarative mirror. See View transitions.

navigate takes a path string only — there is no numeric history overload. For back / forward, reach for the browser API:

window.history.back()      // back
window.history.forward()   // forward

useBlocker()

Hold a pending navigation so you can prompt before the user leaves — the unsaved-changes guard. It guards SPA navigations, the browser's Back/Forward gestures (popstate: the router reverts the already-moved URL and offers retry/reset — this also covers ESC inside an intercepting-route overlay), and full-page unloads via beforeunload.

import { useBlocker } from '@voltro/web'

const blocker = useBlocker(form.isDirty)   // boolean or a predicate
// …
{blocker.blocked && (
  <ConfirmDialog onConfirm={blocker.retry} onCancel={blocker.reset} />
)}

Pass true/false or a predicate ({ to, opts }) => boolean (to allow some destinations). When a navigation is held, the hook returns { blocked: true, to, retry, reset }: retry() proceeds, reset() cancels. A full-page unload also triggers the browser's native prompt while any blocker is active. See Navigation for the full example.

useSearchParams() + useSetSearchParams()

Read the query string. Two overloads, both SSR-aware (the request URL on the server, window.location.search on the client):

  • useSearchParams() — the raw URLSearchParams, for routes without a schema.
  • useSearchParams(searchParams) — pass the page's own searchParams schema export to get the decoded, typed shape. Defaults applied; an invalid query falls back to the defaults instead of crashing.
import { Schema } from 'effect'
import { useSearchParams } from '@voltro/web'

export const searchParams = Schema.Struct({
  tab:  Schema.optionalWith(Schema.String, { default: () => 'overview' }),
  page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
})

const { tab, page } = useSearchParams(searchParams)   // tab: string · page: number
const raw = useSearchParams()                         // URLSearchParams (schema-less routes)

Write it with useSetSearchParams() — the setter updates the query via navigate, so readers re-render immediately:

import { useSetSearchParams } from '@voltro/web'

const setParams = useSetSearchParams()
setParams({ tab: 'members' })                        // set (default: replace)
setParams((p) => { p.set('page', '2'); return p })   // patch one param
setParams({ page: '2' }, { push: true })             // distinct history entry

Writes default to a history replace; pass { push: true } for a Back entry or { scroll: false } to keep scroll. See Navigation.

useSetSearchParams(searchParams) — pass the schema to get the typed setter. Object form replaces the query (a left-out field decodes to its default on the next read); the updater form receives the current decoded params, so a merge is an explicit spread:

const setTyped = useSetSearchParams(searchParams)
setTyped({ page: 2 })                          // replaces → ?page=2
setTyped((p) => ({ ...p, page: p.page + 1 }))  // keeps every other param — typed merge

Typed withQuery()

Not a hook, but the link-side half of the same contract: for a route whose page exports a searchParams schema, the generated routes builder brands the URL with the schema's shape (through a type-only import — no page code enters the routes module), and withQuery type-checks the params against it — a misspelt key or a wrong value type is a compile error:

import { withQuery } from '@voltro/web'
import { routes } from './.framework/routes'

withQuery(routes['/notes'](), { page: 2 })     // OK — typed against the schema
// withQuery(routes['/notes'](), { pgae: 2 })  // compile error (unknown key)

The encode is canonical: strings pass through, numbers/booleans via String(), arrays as repeated keys, undefined omitted; a Date (or any object) is refused loudly — declare the field as a string/number transform in the schema instead. See Navigation → typed withQuery.

usePrefetch()

Trigger loader-data prefetch on hover / focus. Wired automatically by <Link prefetch />; export only useful for custom triggers.

import { usePrefetch } from '@voltro/web'

const Card = ({ id }) => {
  const prefetch = usePrefetch()
  return (
    <article onMouseEnter={() => prefetch(`/notes/${id}`)}>
      {/* …card body, no Link inside */}
    </article>
  )
}

Idempotent — multiple calls for the same path fire one loader. The prefetched result is held in the loader cache until it's consumed by the actual navigation (or invalidated on an error reset).

useLoaderData<T>()

Page's loader output, typed.

// src/pages/notes/[id]/page.tsx
import { useLoaderData } from '@voltro/web'

interface Note {
  id: string
  title: string
}

export const loader = async ({ params }): Promise<Note> => {
  return await fetchNote(params.id)
}

export default function NotePage(): ReactNode {
  const note = useLoaderData<Note>()
  return <article><h1>{note.title}</h1></article>
}

Available in:

  • The page component itself
  • Any layout in the page's chain
  • Any descendant of the layout

Returns null on pages without a loader. The generic narrows the type.

Precisely, it returns LoaderData<T>. For every ordinary loader that IS T. For a loader that returned defer(), LoaderData<T> flattens the two buckets into one object — eager fields as values, deferred fields as Promise<T> — so the compiler tells you which fields have to be rendered through <Await>:

export const loader = async ({ query }) => defer(
  { user: await query('users.me') },
  { report: query('reports.quarterly') },
)

// user: User            report: Promise<Report>
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()

If you wrap this hook in your own generic helper, propagate the mapped type (<D,>(): LoaderData<D> => useLoaderData<D>()) — LoaderData<D> is not assignable to a bare type parameter D.

See Loaders & meta for the server-side counterpart.

Compositions

// "go to next page after a delay"
const navigate = useNavigate()
useEffect(() => {
  const t = setTimeout(() => navigate('/welcome'), 3000)
  return () => clearTimeout(t)
}, [navigate])
// "save the URL the user came from for after-signin redirect"
const pathname = useLocation()
const fromUrl = useMemo(() => pathname, [])     // capture at mount, not every render
// "active link with hover-prefetch"
const Link = ({ to, children }) => {
  const pathname = useLocation()
  const prefetch = usePrefetch()
  const active = pathname === to
  return (
    <a
      href={to}
      onMouseEnter={() => prefetch(to)}
      className={active ? 'active' : ''}
    >
      {children}
    </a>
  )
}
// (You don't usually write this — `<Link>` from `@voltro/web` does it all.)

Anti-patterns

  • window.location.href = '/foo' for internal nav. Full reload — defeats the SPA. Use useNavigate.
  • Reading params outside the page tree. Layouts above the page CAN read params (they share the chain), but components imported as siblings can't. Pass params down explicitly.
  • useEffect(() => navigate(...), []) for default redirects. Triggers a flash of the original page. Do redirects in the LOADER instead (throw new RedirectError(...)).

See also