Link & Navigation

Furin's client-side router provides type-safe navigation, prefetching, and scroll management — all without leaving React.


<Link>

The typed alternative to <a> for internal navigation.

tsx
import { Link } from "@teyik0/furin/link";

<Link to="/docs">Documentation</Link>

Props

PropTypeDefaultDescription
toRouteToDestination path. Auto-completed from furin-env.d.ts.
searchRouteSearch<To>Typed query params for this route.
hashstringURL fragment (without #).
replacebooleanfalseUse history.replaceState instead of pushState.
resetScrollbooleantrueScroll to top (or hash target) after navigation. Set to false for tabs/drawers.
disabledbooleanfalseAdds aria-disabled="true". Right-click still works.
preloadfalse | "intent" | "viewport" | "render""intent"When to preload the target page.
preloadDelaynumber50Delay in ms before "intent" preload triggers.
preloadStaleTimenumber30_000How long a preloaded entry stays fresh.
activeProps(opts) => anchorPropsProps applied when the link matches the current route.
inactiveProps() => anchorPropsProps applied when the link does NOT match.

Active state

tsx
<Link
  to="/docs"
  activeProps={({ isActive }) => ({
    className: isActive ? "text-blue-600" : "text-gray-600",
  })}
>
  Docs
</Link>

Furin also sets data-status="active" on the anchor when active, so you can style via CSS:

css
a[data-status="active"] {
  font-weight: bold;
}

Preload strategies

StrategyTrigger
"intent"Hover or focus (default)
"viewport"Link enters viewport (200px root margin)
"render"Immediately on mount
falseNever preload

Preloading fetches both the HTML payload and the JS chunk in parallel. The browser module cache makes subsequent navigations near-instant.

Typed search

If the route declares a query schema, <Link> infers the correct search param types:

tsx
// Route declares: query: t.Object({ page: t.Number(), tag: t.Optional(t.String()) })
<Link to="/blog" search={{ page: 2, tag: "react" }}>
  Page 2
</Link>

useRouter()

Hook for programmatic navigation and router state.

tsx
import { useRouter } from "@teyik0/furin/link";

function PostActions() {
  const router = useRouter();

  return (
    <button onClick={() => router.navigate("/blog")}>
      Go to blog
    </button>
  );
}

Return value

PropertyTypeDescription
navigate(href, opts?)(string, { replace?, resetScroll? }) => Promise<void>SPA navigation
refresh(opts?)({ resetScroll? }) => Promise<void>Re-fetch current page (cache bust + replaceState)
prefetch(href, opts?)(string, { staleTime? }) => voidManual prefetch
invalidatePrefetch(path, type?)(string, "page" | "layout"?) => voidEvict prefetch cache entries
currentHrefstringCurrent logical pathname + search (basePath stripped)
basePathstringSub-path prefix for static deployments
isNavigatingbooleantrue while a navigation is in flight

navigate()

tsx
// Push new history entry
router.navigate("/blog/my-post");

// Navigate without scrolling
router.navigate("/tabs/settings", { resetScroll: false });

// Replace the current history entry
router.navigate("/search?q=react", { replace: true });

refresh()

Re-fetches the current page in-place. Busts the prefetch cache, re-runs loaders, and updates the tree — without adding a history entry.

tsx
await apiClient.publishPost({ id });
await router.refresh();

Prefer refresh() over router.navigate(window.location.pathname) after a mutation.


useSearch

Read and update the current route's query params with full type safety.

tsx
import { useSearch } from "@teyik0/furin/search";

function ProductList() {
  const [search, setSearch] = useSearch("/products");

  return (
    <div>
      <p>Page {search.page}</p>
      <button onClick={() => setSearch({ page: search.page + 1 })}>
        Next page
      </button>
    </div>
  );
}

useSearch(to)

Returns a tuple with the server-resolved query object and a setter. The type is inferred from the query schema declared on the route chain (createRoute({ query }) in layout or page _route.tsx files).

tsx
// Route declares: query: t.Object({ page: t.Optional(t.Number({ default: 1 })), tag: t.Optional(t.String()) })
const [search, setSearch] = useSearch("/products");
// search: { page: number; tag?: string }

Updating search

The setter shallow-merges patches with the current query params and navigates to the updated URL.

tsx
const [search, setSearch] = useSearch("/products");

// Partial patch — keeps existing params
setSearch({ page: 2 });

// Functional update — receives current resolved search
setSearch((prev) => ({ page: prev.page + 1 }));

Defaults declared in the query schema are resolved in search but omitted from generated URLs. If page defaults to 1, setSearch({ page: 1 }) navigates to /products; setSearch({ page: 2 }) navigates to /products?page=2.

useNavigate()

Use typed navigation when the destination route is not represented by a rendered link.

tsx
import { useNavigate } from "@teyik0/furin/link";

const navigate = useNavigate();

navigate({
  to: "/products",
  search: { page: 2 },
});

Query schema merging across the route chain

When a route inherits from multiple _route.tsx files (layout chain), their query schemas are merged. The generated furin-env.d.ts reflects the union of all query fields in the chain.

src/pages/products/_route.tsx
export const route = createRoute({
  parent: rootRoute,
  query: t.Object({ sort: t.Optional(t.String()) }),
});

// src/pages/products/index.tsx
export default route.page({
  query: t.Object({ page: t.Optional(t.Number({ default: 1 })) }),
  // generated search type: { sort?: string; page: number }
});

Hash-only navigation

When the pathname is identical and only the hash differs, Furin lets the browser handle scrolling natively instead of triggering a full SPA fetch:

tsx
// On /docs/page, clicking this scrolls to #section natively
<a href="#section">Jump to section</a>

This also works for <Link to="/docs/page#section"> when already on /docs/page.


applyRevalidateHeader

If you make manual fetch() calls and want to propagate server-side revalidatePath() invalidations to the prefetch cache:

tsx
import { applyRevalidateHeader } from "@teyik0/furin/link";

const res = await fetch("/api/publish");
applyRevalidateHeader(res.headers, (path, type) => {
  console.log("invalidate", path, type);
});

This is the same utility RouterProvider uses internally to process X-Furin-Revalidate headers.

Comments