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() // forwarduseBlocker()
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 rawURLSearchParams, for routes without a schema.useSearchParams(searchParams)— pass the page's ownsearchParamsschema 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 entryWrites 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 mergeTyped 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. UseuseNavigate.- Reading
paramsoutside 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
- Navigation —
<Link>+ prefetch patterns - Loaders & meta — what populates
useLoaderData