Skip to content

Client navigation

<Link> keeps progressive enhancement: before hydration it is an anchor, and after hydration a plain in-app click fetches page data and swaps the page tree. Use navigateTo() for a path, href() to resolve a named route, and refresh() to fetch the current App, Layout, and Page loaders again without pushing history.

useIsNavigating() returns the router’s current boolean state. It is true through a client navigation, refresh(), or a locale change, and false during SSR. A component mounted after a transition starts reads the current state immediately; there is no delay. When a newer transition supersedes an earlier one, completion of the earlier work cannot clear the newer pending state. A page action that redirects keeps useIsSubmitting() true through this same navigation, until the redirected page is on screen.

Use it for a pending indicator, not a percent-complete claim:

src/web/components/navigation-progress.tsx
import { useIsNavigating } from "@warlock.js/web";
import "./navigation-progress.css";
export function NavigationProgress() {
const isNavigating = useIsNavigating();
return (
<div
aria-hidden={!isNavigating}
aria-label="Loading next page"
className={
isNavigating ? "navigation-progress navigation-progress--active" : "navigation-progress"
}
/>
);
}
src/web/components/navigation-progress.css
.navigation-progress {
position: fixed;
inset: 0 0 auto;
height: 3px;
transform: scaleX(0);
transform-origin: left;
}
.navigation-progress--active {
transform: scaleX(0.7);
transition: transform 160ms ease-out;
}

The boolean means that router work is active. It does not expose network or rendering percentage, so do not turn it into a numeric progress meter.

routerEvents remains available for analytics and post-navigation observation. Use useIsNavigating() for reactive UI state; do not infer pending state from events, because an event callback can miss a transition that began before the component subscribed.

import { apiHref, href, navigateTo, refresh } from "@warlock.js/web";
navigateTo({ name: "products.details", params: { id: "42" }, query: { tab: "reviews" } });
navigateTo("/cart", { replace: true });
const productUrl = href("products.details", { params: { id: "42" }, query: { tab: "reviews" } });
const reviewsEndpoint = apiHref("products.reviews", { params: { id: "42" }, query: { page: 2 } });
await refresh();

navigateTo() accepts a path or a { name, params, query } route target. Before the client runtime is connected (for example, a click before hydration finishes), it falls back to a real browser navigation, so you never need to write window.location.assign yourself. It returns false only on the server, or when the runtime declines a same-origin URL.

href() resolves named page routes through the server-published route table. It takes either positional arguments (href(name, params, query)) or an object (href(name, { params, query })). apiHref() does the same for named API routes, which is useful for fetch URLs. Both accept only the names in the current page’s route table; see Form submission. Generated declarations in .warlock/typings/web-routes.d.ts make page names and required path parameters type-safe when they are available; runtime validation remains active before generation. refresh() leaves history unchanged and resolves false when fresh data did not reach the screen.

LinkProps is exported from @warlock.js/web when a shared component should accept the exact same destination contract as <Link>:

import { Link, type LinkProps } from "@warlock.js/web";
export function NavigationItem(props: LinkProps) {
return <Link {...props} />;
}

This keeps generated named-route and parameter checking intact. Do not redeclare destination props as broad strings in a wrapper.

To take props away, use LinkPropsWithout<Keys> rather than Omit<LinkProps, Keys>. LinkProps is a union (route name with its params, literal URL, email, tel), and a plain Omit collapses it to the keys the members share, losing the required-params check. LinkPropsWithout omits from each member (DistributiveOmit<T, Keys> is exported for other unions):

import { Link, type LinkPropsWithout } from "@warlock.js/web";
export function MenuLink(props: LinkPropsWithout<"className">) {
return <Link {...props} className="menu-link" />;
}

A route name that only exists at runtime (a menu entry, a shortcut descriptor read from data) narrows with isRouteName(name), which checks the published route table, so it can go to <Link to> or href() without a cast:

import { isRouteName, Link } from "@warlock.js/web";
function ShortcutLink({ route, label }: { route: string; label: string }) {
return isRouteName(route) ? <Link to={route}>{label}</Link> : null;
}

Refresh lifecycle events (onNavigating, onNavigated, onNavigationError) carry reason: "refresh" for an explicit refresh(), "live" for live-data invalidation, "poll" for polling/reconnect catch-up, and "focus" for window focus catch-up on live pages. Ordinary route navigations omit reason.

import { refresh, routerEvents } from "@warlock.js/web";
const stop = routerEvents.onNavigated((event) => {
if (event.reason === "refresh") console.log("Refresh complete");
});
await refresh(); // explicit/user refresh
await refresh({ reason: "poll" }); // app-owned background revalidation
stop();

Background reasons bypass page caches. Built-in live updates still wait for navigation and unsent form edits; explicit refreshes remain user requests. RefreshReason and RefreshOptions are exported types. Filter background reasons explicitly instead of treating every history replace as background.

Each successful client page/shell swap installs and recursively freezes that payload’s shared snapshot before rendering. Retained components using useShared() subscribe to the new snapshot, including across consecutive shell switches. Write shared values in server middleware on each request; client writes remain refused.