clear-react-router
v2.3.2
Published
A lightweight routing library for React applications
Maintainers
Readme
Clear Router
A lightweight routing library for client side React applications with nested routes, data loading, navigation blocking, prefetching, and route actions.
Why Clear Router?
Most React routers focus on flexibility and ecosystem integrations. Clear Router focuses on predictable navigation with a small, explicit API and minimal setup.
There is no RouterProvider or provider hierarchy to manage. Simply render <Router /> once, and use router hooks anywhere in your application.
It provides first-class support for:
- Predictable routing
- Built-in data loading
- Route actions
- Simple, provider-free architecture
- Small, explicit API
👉 TL;DR? Try the live playground — no setup, click around.
Features
- Nested Routes - Organize your UI with nested layouts and routes
- Data Loading - Built-in loaders with TTL-based caching (
staleTime) - Navigation Blocking - Prevent accidental navigation with
useBlocker - Smooth Animations - Page transitions with fade effect (customizable duration)
- Static Layout — Keep navbar, footer, and other elements outside the router to avoid unnecessary re-renders
- Programmatic Redirects - Redirect from beforeLoad hook
- Cache invalidation - Manual route invalidation
- Bounded Cache - Automatically evicts least recently used entries once
maxCacheSizeis reached, keeping memory usage predictable in long sessions - Prefetching - Preload data on hover for instant navigation
- Lazy Loading - Code-split your routes with dynamic imports for optimal performance
- Scroll Restoration — Automatically saves and restores scroll position when navigating back to a page (preserves user's scroll position)
- Optimistic navigation — Instantly renders stale cached data while fresh data is loaded in the background.
- Browser History - Full support for browser back/forward buttons
- Context-aware - Pass and update context through routes
API
Router
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| routes | RouteItem[] | required | Array of route configurations |
| maxCacheSize | number \| undefined | 60 for mobile, 150 for desktop | Maximum number of cached loader entries. Once the limit is reached, the least recently used entries are evicted |
| isAnimated | boolean \| undefined | false | Enable smooth page fade transitions |
| animationDuration | number \| undefined | optional | Animation duration in milliseconds (browser default is used if not set) |
| context | object | {} | Initial context (user, theme, etc.) |
| errorBoundary | ComponentType<{ children: ReactNode }> | undefined | Custom error boundary component for catching render errors in route components |
| defaultMinLoaderDuration | number \| undefined | 0 | Default minimum time the loader fallback stays visible, to avoid flickering |
| defaultLoaderFallback | ReactElement \| () => ReactElement | optional | Default loading fallback for every route loader |
| defaultErrorElement | ReactElement \| () => ReactElement | optional | Default error fallback for every route |
| defaultRetry | number \| { count: number; delay: number } | optional | Default cache revalidation retry policy for all routes |
| defaultStaleTime | number | optional | Default time in milliseconds before cached loader data is considered stale |
| defaultBeforeLoad | ({ params, context, redirect, setContext, location }) => Promise<unknown> \| undefined \| void | undefined | Runs before every navigation. Useful for authentication, analytics, or updating shared context. |
| defaultAfterLoad | ({ params, context, searchParams }) => Promise<void> | undefined | Runs after every successful navigation. Useful for analytics, page tracking, or other global side effects. |
| defaultPrefetch | 'hover' \| 'render' \| 'viewport' \| 'none' | 'hover' for desktop, 'viewport' for mobile | Default prefetch strategy for all <Link> components |
| defaultHoverPrefetchDelay | number \| undefined | 150 | Default delay in milliseconds before prefetching on hover (only for 'hover' strategy) |
| defaultScrollRestorationBehavior | 'auto' \| 'smooth' \| 'instant' | auto | Default scroll restoration behavior |
| revalidateOnFocus | boolean \| undefined | undefined | Revalidate the current route's loader data when the browser tab regains focus |
| revalidateOnReconnect | boolean \| undefined | undefined | Revalidate the current route's loader data when the browser regains an internet connection |
Note: Global lifecycle hooks wrap every route navigation. The global
defaultBeforeLoadruns before the route-specific beforeLoad, while the globaldefaultAfterLoadruns after the route-specific afterLoad.
createRouter(routes)
Normalizes route configuration. Extracts dynamic params, builds nested paths.
| Property | Type | Description |
|----------|------|-------------|
| path | string | Route path, e.g., /user/:userId |
| element | ReactElement \| () => ReactElement \| LazyComponent | Component to render |
| beforeLoad | ({ params, context, redirect, setContext, location }) => Promise<unknown> \| undefined \| void | Runs before every route navigation. Auth checks and redirects. Can update context via setContext. redirect is provided by the router |
| loader | ({ params, context, setContext, searchParams, signal }) => Promise<unknown> | Fetch data using route params, search params, abort controller signal and context. Can update context via setContext |
| afterLoad | ({ params, context, searchParams }) => Promise<void> \| void | Runs after a successful navigation once the route has finished loading. Analytics, side effects after data is loaded. Can update context via setContext |
| minLoaderDuration | number \| undefined | undefined | Minimum time the loader fallback stays visible, to avoid flickering |
| fallback | ReactElement \| () => ReactElement | Loading fallback (for lazy loading) |
| loaderFallback | ReactElement \| () => ReactElement | Loading fallback for the route's loader. Overrides the global Router.defaultLoaderFallback |
| retry | number \| { count: number; delay: number } | Overrides the global cache revalidation retry policy for this route |
| optimistic | boolean \| undefined | Instant navigation using stale data while fresh data is loaded in the background |
| errorElement | ReactElement \| () => ReactElement | Error fallback for the route. Overrides the global Router.defaultErrorElement |
| staleTime | number \| undefined | Time in milliseconds before cached loader data is considered stale. Overrides Router.defaultStaleTime. If neither value is provided, cached data never expires |
| gcTime | number \| undefined | How long, in milliseconds, an unused cache entry is kept in memory after you navigate away, before it's garbage-collected |
| actions | ({ params, context, searchParams, setContext, location }) => Record<string, (data: Record<string, unknown>) => unknown \| Promise<unknown>> | Defines route actions for data mutations. |
| pollingInterval | number \| undefined | Polling interval (in milliseconds) for automatically revalidating data while the route is active. Each tick refetches unconditionally, ignoring staleTime |
| scrollRestoration | boolean \| string[] \| undefined | Restore scroll position when navigating back to this route. true restores the window scroll; a string array restores scroll inside specific scrollable elements, matched by their id |
| scrollRestorationBehavior | 'auto' \| 'smooth' \| 'instant' | Scroll restoration behavior |
beforeLoad, loader and actions receive:
{
params: Record<string, string>; // Route parameters
context: Record<string, unknown>; // Router context
setContext: Dispatch<SetStateAction<Record<string, unknown>>>; // Updates the router context
searchParams: Record<string, string>; // URL search parameters
location: Location; // Route location
}see Location and Where you came from for type details
beforeLoad additionally receives:
{
redirect: (arg: Location | string) => Promise<void>; // Programmatic redirection, see [redirect](#redirect)
}see redirect for details on programmatic redirects
loader additionally receives:
{
signal: AbortSignal; // Aborted if a newer navigation supersedes this one — pass to fetch() to cancel in-flight requests
}Note:
gcTimeis independent fromstaleTime—staleTimecontrols when cached data is considered outdated (and needs a refetch), whilegcTimecontrols how long the cache entry stays in memory at all once you leave that route. Data can be fresh and still get garbage-collected, or stale and still linger in memory, depending on which you set. The timer starts only when you navigate away from the route and is cancelled if you come back before it fires — it isn't affected by prefetching. This is mainly useful for routes with heavy or fast-changing data (large tables, dashboards with charts) that you don't want lingering in memory indefinitely, even whenmaxCacheSizehasn't been reached yet. If a route is bothoptimisticand hasgcTimeset, keep in mind the cached snapshot can disappear while you're relying on it for an instant render — combine the two deliberately, not by default.
Link
Component for client-side navigation with prefetch support, active state detection, and pending state styling. Prefetch includes lazy route component preload.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| to | string | required | Target path |
| search | string \| Record<string, string \| number \| boolean \| null \| undefined> \| undefined | undefined | Query string or object appended to the target path |
| state | unknown | undefined | Arbitrary value attached to the navigation entry |
| as | (props: ElementProps<T>, state: { isActive: boolean; isPending: boolean }) => ReactElement | renders <a> | Render function for using a custom element/component instead of the default . Receives the props to spread onto your element (href, ref, event handlers, className, style, children) as the first argument, and { isActive, isPending } as a separate second argument — kept separate so these values are never accidentally forwarded to the DOM |
| exact | boolean | false | When false, the link is also considered active if the current URL starts with to (useful for nested routes) |
| prefetch | 'hover' \| 'render' \| 'viewport' \| 'none' | Router config | Override the global prefetch strategy |
| hoverPrefetchDelay | number | Router config | Override the global hover delay |
| children | ReactNode | required | Content to render inside the link |
| className | string \| ({ isActive, isPending }) => string | undefined | CSS class name(s). Can be a function for dynamic styling |
| style | CSSProperties \| ({ isActive, isPending }) => CSSProperties | undefined | Inline styles. Can be a function for dynamic styling |
| activeClassName | string optional | 'active-link' | Class name applied when the link matches the current URL |
| pendingClassName | string optional | 'pending-link' | Class name applied when the link's target is loading |
| beforeNavigate | () => Promise<void> \| undefined | undefined | Callback fired before navigation |
State values:
| State | Type | Description |
|-------|------|-------------|
| isActive | boolean | true when the link's to matches the current URL considering exact value |
| isPending | boolean | true when the target route is currently loading (loader is running) |
Accessibility
Active links expose aria-current="page" so screen readers announce the current page, and links whose target is loading expose aria-busy="true" while the loader runs. Both attributes are omitted when inactive, keeping the DOM clean.
Extra data-* and aria-* props passed to <Link> (e.g. data-testid, aria-label) are forwarded to the rendered element:
<Link to="/about" aria-label="About section" data-testid="about-link">
About
</Link>When using a custom element via as, these attributes arrive in props together with everything else — spread them onto the host element as usual.
Prefetch Strategies
| Strategy | Behavior |
|----------|----------|
| 'hover' | Prefetches when the user hovers over the link (with configurable delay) |
| 'render' | Prefetches immediately when the link is rendered |
| 'viewport' | Prefetches when the link enters the viewport (using Intersection Observer) |
| 'none' | No prefetching |
Custom elements via as
When using as to render a custom component instead of the default <a>, your component must spread all received props onto the underlying host element — including ref. If any prop is dropped, the corresponding feature silently stops working (no error is thrown):
- Missing
ref→viewportprefetch never triggers (theIntersectionObserverhas nothing to observe). - Missing
onClick→ navigation doesn't happen, the link just does nothing. - Missing
onMouseEnter/onMouseLeave→hoverprefetch doesn't trigger. - Missing
href→ the link isn't reachable via keyboard, screen readers, "open in new tab", etc.
// ✅ correct — every prop is forwarded to the host element
const Button = ({ children, ...props }: ElementProps<HTMLButtonElement>) => (
<button {...props}>{children}</button>
);
// ❌ wrong — ref, event handlers, and href are silently dropped
const Button = ({ children }: { children: ReactNode }) => (
<button>{children}</button>
);If you only want to add or override specific props (e.g. add a variant), spread the received props first, then apply your own on top:
const Button = ({ children, ...props }: ElementProps<HTMLButtonElement>) => (
<button {...props} className={`btn ${props.className ?? ''}`}>
{children}
</button>
);Note: Because
asis called as a plain function rather than rendered via JSX, avoid using React hooks (useState,useEffect, etc.) inside the function you pass toas— it isn't tracked by React as a separate component in the fiber tree. A function written foras(likeButtonabove, which takes a secondstateargument) also isn't a valid standalone React component and shouldn't be rendered directly as<Button />elsewhere.
Example:
import { Link, type ElementProps } from 'clear-react-router';
const Button = (
{ children, ...rest }: ElementProps<HTMLButtonElement>,
{ isActive }: { isActive: boolean }
) => (
<button {...rest} style={{ background: isActive ? 'tomato' : 'green' }}>
{children}
</button>
);
<Link to="/about" as={Button}>To about page</Link>
For third-party components (MUI, Chakra, etc.), wrap them in an inline arrow function — most of them accept a
single `props` argument and forward it to the host element themselves:
import { Button } from '@mui/material';
<Link
to="/about"
as={(props, { isActive }) => <Button {...props} variant={isActive ? 'contained' : 'text'} />}
>
To about page
</Link>// Global prefetch: hover with 100ms delay
<Router routes={routes} defaultPrefetchh="hover" defaultHoverPrefetchDelay={100} />
// Override for a specific link
<Link to="/heavy-page" prefetch="viewport">
Heavy Page
</Link>
// Disable prefetch for a specific link
<Link to="/admin" prefetch="none">
Admin Panel
</Link>
// With custom active/pending classes
<Link to="/settings" activeClassName="active-nav-link" pendingClassName="loading-nav-link">
Settings
</Link>
// With dynamic className
<Link to="/dashboard" className={({ isActive, isPending }) => isActive ? 'text-blue-600' : isPending ? 'text-gray-400' : 'text-gray-600'}>
Dashboard
</Link>
// With dynamic style and search object
<Link to='/profile' search={{ user: 'John Doe' }} style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })}>
Profile
</Link>
// Use `beforeNavigate`
<Link to="/details" beforeNavigate={saveDashboardData}>
Admin Panel
</Link>
// `exact={false}` — active for nested routes too
// e.g. active when current URL is "/settings" or "/settings/profile"
<Link to="/settings" exact={false}>
Settings
</Link>Important: prefetch="render" should be used sparingly, as it preloads data immediately when the link is rendered, which may cause unnecessary network requests.
Retry
Sometimes a request may fail because of a temporary network issue or a short-lived server problem. Instead of immediately rendering the error state, you can configure the router to automatically retry loading route data.
Route-level retry
{
path: '/posts',
loader: loadPosts,
retry: 3,
}retry: 3 means the router will make up to 3 additional attempts after the initial failed request (up to 4 attempts in total).
You can also specify a delay between attempts:
{
path: '/posts',
loader: loadPosts,
retry: {
count: 3,
delay: 500,
},
}Global retry
To apply the same retry policy to all routes, use defaultRetry:
<Router routes={routes} defaultRetry={2} />or with a delay:
<Router routes={routes} defaultRetry={{ count: 2, delay: 500 }} />A route-level retry always overrides defaultRetry.
How it works
Unlike many routing libraries, retry is not limited to the initial loader execution.
The retry policy is applied to the router's cache revalidation mechanism, so it automatically works for every operation that reloads route data, including:
- Initial route loading
- Cache revalidation
invalidate()prefetch()
This ensures consistent behavior regardless of how the data is being refreshed.
Why?
The router treats the route loader as the single source of truth for route data. Since every data refresh goes through the same cache revalidation pipeline, retry is configured once and automatically applies everywhere without any additional code.
redirect
Function provided to beforeLoad for programmatic redirection.
Type: (arg: Location | string) => Promise<void>
import type { createRouter } from 'clear-react-router';
const routes = createRouter([
{
path: '/dashboard',
element: <Dashboard />,
beforeLoad: ({ context, redirect }) => {
if (!context.isAuthorized) return redirect('/');
},
},
]);
const routes = createRouter([
{
path: '/dashboard',
element: <Dashboard />,
beforeLoad: ({ context, redirect }) => {
if (!context.isAuthorized) return redirect({ pathname: '/login', state: { from: '/dashboard' } });
},
},
]);
const routes = createRouter([
{
path: '/user/:userId',
loader: async ({ params, context, setContext }) => {
const user = await fetchUser(params.userId);
setContext({ ...context, currentUser: user });
return { user };
},
beforeLoad: async ({ context, setContext, redirect }) => {
if (!context.token) return redirect('/login');
setContext({ ...context, lastVisit: Date.now() });
},
}
]);
Usage with Parameters
The loader, beforeLoad, and afterLoad hooks receive params (extracted from the URL) and context as arguments. This allows you to handle route-specific logic directly in the route configuration, keeping your components focused on rendering.
import type { createRouter } from 'clear-react-router';
const routes = createRouter([
{
path: '/user/:userId',
element: <UserProfile />,
loader: async ({ params, context }) => {
// params.userId is available from the URL
const user = await fetchUser(params.userId);
return { user };
},
beforeLoad: async ({ params, context, redirect }) => {
// Authentication check
if (!context.isAuthorized) {
return redirect('/login');
}
// Validate parameter
if (!params.userId || !isValidUserId(params.userId)) {
return redirect('/users');
}
},
afterLoad: ({ params, context }) => {
// Analytics or side effects
console.log(`User ${params.userId} loaded`);
},
},
]);Route Actions
Defines route-specific actions for handling data mutations such as creating, updating, or deleting resources.
Actions are available through the useSubmitAction and useAction hooks. After a successful action, the current route is automatically invalidated, causing loader to run again in the background.
actions?: ({ params, context, searchParams, setContext, location }) => ({
save: async (data: Record<string, unknown>) => await api.updatePost(params.id, data),
remove: async () => await api.deletePost(params.id),
})Arguments
{
params: Record<string, string>; // Route parameters
context: Record<string, unknown>; // Router context
setContext: Dispatch<SetStateAction<Record<string, unknown>>>; // Updates the router context
searchParams: Record<string, string>; // URL search parameters
location: Location; // Route location
}Returns
A record where each key is an action name and each value is a function accepting an object data argument.
useSubmitAction()
useSubmitAction wraps a route's action with submission state (isSubmitting, data, error) and gives you two ways to trigger it — a ready-made onSubmit for native forms, or a raw submit(data) you can call from any form library.
import { useSubmitAction } from 'clear-react-router';
const CreateUserForm = () => {
const { onSubmit, isSubmitting, error } = useSubmitAction('createUser');
return (
<form onSubmit={onSubmit}>
<input name="email" disabled={isSubmitting} />
<button disabled={isSubmitting}>Create</button>
{error && <span>{error.message}</span>}
</form>
);
};submit(data: Record<string, unknown>) accepts a plain JS object, so it works with any form library that gives you validated values — not just native <form> submissions:
import { useForm } from 'react-hook-form';
const { register, handleSubmit } = useForm<FormValues>();
const { submit, isSubmitting } = useSubmitAction('createUser');
<form onSubmit={handleSubmit(submit)}>
<input {...register('email')} disabled={isSubmitting} />
</form>Arguments:
| Argument | Type | Default | Description |
|---|---|---|---|
| action | string | — | The action key defined in the route's actions |
| options.onSuccess | (data: unknown) => void \| undefined | undefined | Called after a successful submission |
| options.onError | (error: unknown) => void \| undefined | undefined | Called if the action throws |
| options.autoReset | boolean \| undefined | true | Reset the form element after a successful native submission (onSubmit only — has no effect on submit) |
Return value:
| Field | Type | Description |
|---|---|---|
| submit | (data: Record<string, unknown>) => Promise<{ data: unknown; error: Error \| null }> | Runs the action directly, from any form data source |
| onSubmit | (evt: SubmitEvent<HTMLFormElement>) => Promise<void> | Ready-made handler for a native <form onSubmit={...}> |
| data | unknown | Result of the last successful submission |
| error | Error \| null | Error from the last failed submission |
| isSubmitting | boolean | true while the action is in flight |
The native
onSubmithandler converts the form viaObject.fromEntries(new FormData(...))— if your form has multiple fields sharing the samename(checkbox groups,<select multiple>), only the last value survives. Usesubmit(data)with your own data source (e.g. React Hook Form) if you need to preserve multiple values per field.
useAction()
useAction provides direct access to a route action.
Arguments
| Argument | Type | Description |
|----------|------|-------------|
| action | string | Name of the route action to execute. Must match a key returned from the route's actions configuration. |
| options | Options | Optional callbacks invoked after the action succeeds or fails. |
type Options = { onSuccess?: (args: unknown) => void; onError?: (args: unknown) => void };const save = useAction('save');
const handleClick = async () => await save({ data: 'Hello world!' });
<button onClick={handleClick}>Save</button>useAction automatically invalidates the current route after a successful action, causing loader to run again in the background.
This hook is useful when the mutation is triggered programmatically, such as from dialogs, context menus, drag-and-drop interactions, keyboard shortcuts, or custom UI components.
Error Boundaries
You can provide a custom error boundary to catch rendering errors in route components. This is useful for preventing the entire app from crashing when a specific route fails to render.
import { Router } from 'clear-react-router';
import { routes } from './routes';
import { ErrorBoundary } from './components/ErrorBoundary';
const App = () => <Router routes={routes} errorBoundary={ErrorBoundary} />Note: The errorBoundary prop only catches render-time errors in route components. It does not catch errors in loader or beforeLoad — those are handled by the router's errorElement mechanism.
Hooks
useNavigate()
Returns a function to navigate programmatically. Accepts a string (pathname, optionally with a ?query), a NavigationLocation object, or -1 to go back.
type NavigationLocation = { pathname: string; search?: string | Record<string, string | number | boolean | null | undefined>; state?: unknown }
const navigate = useNavigate();
navigate('/about'); // string
navigate({ pathname: '/user/123', search: { user: 'Jane Doe' }, state: { from: 'home' } }); // NavigationLocation
navigate(-1); // go backNote: Navigation state can be accessed via useLocation():
const navigate = useNavigate();
navigate({ pathname: '/profile', state: { userId: 123 } });
// In Profile component
const { state } = useLocation();
console.log(state); // { userId: 123 }useParams<T>()
Returns route parameters object.
const params = useParams<{ userId: string }>();
// URL: /user/123 → params.userId === '123'useLocation()
Returns current location { pathname, search, state }.
type Location = {
pathname: string;
search?: string;
state?: unknown;
prevLocation?: Omit<Location, 'state' | 'prevLocation'>;
}
const { pathname, search, state } = useLocation();Where you came from
Every location carries prevLocation — the pathname and search of the route you
navigated away from. Useful for a contextual "Back" link, or for redirect logic in
beforeLoad:
const location = useLocation();
<Link to={location.prevLocation?.pathname ?? '/'}>← Back</Link>// beforeLoad
beforeLoad: async ({ location, redirect }) => {
if (!isAuthenticated && location.prevLocation?.pathname !== '/login') {
await redirect('/login');
}
}
prevLocationreflects only the immediately preceding route — it doesn't accumulate a full navigation history, and it'sundefinedon the very first render (nothing to navigate away from yet).
useLoaderState<T>()
Returns the cached data loaded by the current route's loader, along with any errors from loader or beforeLoad. Data is automatically cached and reused when navigating back to the same route.
Returns:
| Property | Type | Description |
|----------|:----:|-------------|
| data | T | The data returned from the route's loader |
| loaderError | Error \| null | Error from the loader (if any) |
| beforeLoadError | Error \| null | Error from the beforeLoad hook (if any) |
const UserProfile = () => {
const { data, loaderError, beforeLoadError } = useLoaderState<User>();Caching behavior:
- The loader result is cached and reused when navigating back to the same route (e.g., from /user/123 back to /user/456 it will be a new request because different params, but from /user/456 to /user/456 — cache hit).
- Use
staleTimein route config to control how long cache is considered fresh:
{
path: '/user/:userId',
loader: async ({ params }) => fetchUser(params.userId),
staleTime: 60000, // 1 minute — cache is fresh for 60 seconds
}- The cache is bounded by
maxCacheSize— once the limit is reached, the least recently used entry is evicted to make room for a new one, regardless of whether it's still fresh. This caps memory usage for apps with many high-cardinality dynamic routes (e.g./product/:idacross a large catalog). It defaults to a device-aware value (lower on mobile) and can be overridden on theRouter:
<Router routes={routes} maxCacheSize={200} />useInvalidate()
Returns a function that revalidates cached route data. Calling invalidate() clears cached route data and immediately runs the corresponding route loader again.
Current route
Revalidate the currently active route:
const invalidate = useInvalidate();
await invalidate();Specific route
Revalidate any registered route by passing its pathname:
await invalidate('/posts');Multiple routes
You can revalidate several routes at once by passing an array of pathnames:
await invalidate(['/posts', '/profile', '/settings']);Dynamic routes
When a dynamic route pattern is provided, every cached route that matches the pattern will be revalidated.
For example:
await invalidate('/post/[id]');will revalidate all cached routes such as:
/post/1
/post/17
/post/42This also works for nested dynamic routes:
await invalidate('/post/[id]/comment/[id]');Force revalidation
By default the exact path(s) you pass are always revalidated, even if they were never cached
— this also makes invalidate() work on an error page with an empty cache.
Pass { force: false } to go back to cache-only revalidation:
await invalidate('/about', { force: false });Explicit { force: true } behaves the same as the default; it exists for readability
and for combining with other options (see below).
Stale-only revalidation
By default, invalidate deletes matching cache entries and refetches them.
Pass { staleOnly: true } to only revalidate entries that are already stale, leaving fresh ones untouched:
await invalidate('/notes', { staleOnly: true });Note: staleOnly disables the default force — uncached paths are left alone, so no new
network requests are started for routes you never visited.
Including child routes
To revalidate routes together with their cached child routes, pass the withChildren option:
await invalidate('/posts', { withChildren: true });
await invalidate(['/posts', '/users'], { withChildren: true });This will recursively revalidate all cached child routes.
For example, if the following routes have been visited:
/posts
/post/17
/post/23
/post/42/commentsthen:
await invalidate('/posts', { withChildren: true });will revalidate the cached child routes:
/post/17
/post/23
/post/42/commentsStatus transitions
When the revalidated path is the currently active route, a successful revalidation also
resets its status to active, while a failed one sets it to error — so recovering from
an error page is just await invalidate():
// On an errorElement: refetch, and show the page again on success
await invalidate();Revalidating any other route never touches the current page status — background refreshes stay invisible.
Returns
An array of objects with the following structure:
{ path: string; data: unknown; error: unknown }Each object represents a revalidated route, where path is the route pathname, data is the revalidated loader result, and error is the loader error, if any.
Notes
- Without
force: false, the exact pathnames you pass are always revalidated (and stored in the cache), even if they were never cached. Pass{ force: false }to revalidate only routes that already have cached data. - With
{ staleOnly: true }, uncached paths are never fetched — the default force does not apply. - Cached data is cleared before the new loader starts.
- When used as an event handler, wrap the call in an arrow function:
<button onClick={() => invalidate()}>Refresh</button>Passing invalidate directly (onClick={invalidate}) is not supported because React passes a MouseEvent object to event handlers.
useBlocker(callback)
Blocks navigation - including the browser's Back/Forward buttons - while callback returns true.
The library calls callback on every navigation attempt with the current, target location and router context, so you can decide whether to block based on where the user is headed, not just your app's internal state:
callback: (arg: { location: Location; nextLocation: Location | null; context: Record<string, unknown> }) => booleanlocation- the route the user is currently on.nextLocation- where they're trying to go.nullwhen nothing is blocked yet.context- router context.
Returns:
| Property | Type | Description |
|----------|------|-------------|
| state | 'unblocked' \| 'charged' \| 'blocked' | 'unblocked' — callback returns false, navigation isn't intercepted. 'charged' — callback returns true, blocking is armed, but no navigation has been attempted yet. 'blocked' — a navigation attempt was just intercepted; nextLocation inside callback now points to the attempted target. |
| process() | () => void | Confirm and complete the blocked navigation |
| reset() | () => void | Cancel the blocked navigation and stay on the current route |
const { state, process, reset } = useBlocker(({ nextLocation }) => hasUnsavedChanges);
useEffect(() => {
if (state === 'blocked') {
// Show your custom modal
if (confirm('Leave without saving?')) {
process();
} else {
reset();
}
}
}, [state, process, reset]);Works for programmatic navigation and browser Back/Forward alike — including the case where the URL already changed via Back/Forward, which the library reverts until
process()orreset()is called.
Custom Blocker component
If you'd rather not manage the useEffect or conditional rendering yourself, you can wrap useBlocker in a small render-prop component:
type BlockerProps = {
children: (blocker: ReturnType<typeof useBlocker>) => ReactNode;
callback: (args: BlockerCallback) => boolean;
};
const Blocker = ({ callback, children }: BlockerProps) => {
const blocker = useBlocker(callback);
return blocker.state === 'blocked' ? children(blocker) : null;
};<Blocker callback={() => formState.isDirty}>
{({ process, reset }) => <Dialog onConfirm={process} onClose={reset} />}
</Blocker>useRestoreScroll()
Returns a callback that restores the saved scroll position for the current route — the same restoration the router performs automatically after every navigation, but triggered manually whenever you need it.
The automatic restore covers content that is ready when navigation finishes (including route loader data). Call the callback yourself for content that arrives later:
- deferred data streamed via React 19
use(), - lazily loaded route chunks,
- any container that mounts after the initial render.
import { useRestoreScroll, useLoaderState } from 'clear-react-router';
const SlowTable = () => {
const restoreScroll = useRestoreScroll();
const { data } = useLoaderState<{ rows: Promise<Row[]> }>();
return (
<Suspense fallback={<Skeleton />}>
<Table rows={data.rows} onReady={restoreScroll} />
</Suspense>
);
};Data fetched outside the route loader (e.g. a useEffect fetch inside the component) is invisible to the router, so the automatic restore can't account for it either. The rule of thumb is simple: whenever your data arrives and the scrollable content is in place, call the callback:
const Dashboard = () => {
const restoreScroll = useRestoreScroll();
const [rows, setRows] = useState<Row[] | null>(null);
useEffect(() => {
fetchRows().then(data => {
setRows(data);
// DOM updates after setState — wait a frame so the container exists
requestAnimationFrame(() => restoreScroll());
});
}, [restoreScroll]);
return rows ? <Table rows={rows} /> : <Skeleton />;
};Details worth knowing:
- The callback reads the latest saved positions at call time, not at render time — so it stays correct even if called long after mount, and repeated calls pick up the newest data. It also works from components outside
<Router>(e.g. a static navbar), since it doesn't depend on the render tree. - Calling it is always safe: it silently does nothing when there is nothing saved for the current route, or when the route opted out with
scrollRestoration: false. - An optional behavior argument on the hook overrides the scroll behavior for these calls (
useRestoreScroll('smooth')); by default the route'sscrollRestorationBehavior, then the router'sdefaultScrollRestorationBehavior, applies. - For element-level restoration (
scrollRestoration: ['panel']), containerids must be unique and stable across visits — a typo or a remountedidlogs a warning and skips that container.
useRouteStatus()
Returns the current route status ('idle' | 'pending' | 'active' | 'optimistic' | 'error'), or — when called with a predicate — whether it matches. Useful for global loading indicators (progress bar, spinner in the layout, etc.) and for reacting to specific states.
import { useRouteStatus } from 'clear-react-router';
const isLoading = useRouteStatus(status => status === 'pending' || status === 'optimistic');
const isOptimistic = useRouteStatus(status => status === 'optimistic');To render your own loading indicator (for example next to a link while its target revalidates), combine it with the link state:
import { Link, useRouteStatus } from 'clear-react-router';
const NavLink = ({ to, children }: { to: string; children: ReactNode }) => {
const isLoading = useRouteStatus(status => status === 'pending' || status === 'optimistic');
return (
<Link to={to} className={({ isPending }) => (isPending ? 'link-pending' : '')}>
{children}
{isLoading && <span aria-hidden="true"> •••</span>}
</Link>
);
};For per-link pending styling without custom components, pendingClassName and the className/style functions (which receive { isActive, isPending }) cover most cases with no extra code.
Migration note:
useIsDataLoading()is removed — it is exactlyuseRouteStatus(status => status === 'pending' || status === 'optimistic').
useRouterContext()
Returns the router context object and a function to update it. Useful for accessing or modifying global state (like user authentication, theme, etc.) from anywhere in your app.
const { setContext, context } = useRouterContext();
const loginHandler = () => setContext({ ...context, user: { name: 'John' } });useSearchParams()
Returns an object for working with URL query parameters. Supports reading and setting both single values and arrays.
import { useSearchParams } from 'clear-react-router';
function ProductFilter() {
const { searchParams, getSearchParams, setSearchParams } = useSearchParams();
// Get a single value or array
const brand = getSearchParams('brand'); // 'nike' | ['nike', 'reebok'] | ''
// Set a single value
setSearchParams('brand', 'nike'); // ?brand=nike
// Set multiple values (array)
setSearchParams('brand', ['nike', 'reebok']); // ?brand=nike&brand=reebok
// Functional update (preserves other params)
setSearchParams((prev) => {
prev.set('page', '2');
prev.append('color', 'red');
return prev;
});
// Direct access to URLSearchParams
const allParams = searchParams.toString(); // "brand=nike&brand=reebok&page=2"
}Returns:
| Property | Type | Description |
|----------|------|-------------|
| searchParams | URLSearchParams | Raw URLSearchParams object for low-level access |
| getSearchParams | (key: string) => string \| string[] | Returns a single value or an array if multiple values exist for the key |
| setSearchParams | (param: string, value: string \| string[]) => void or (updater: (prev: URLSearchParams) => URLSearchParams) => void | Update query parameters. Supports single values, arrays, or functional updates |
Key features:
- Array support —
getSearchParamsreturnsstring[]when multiple values exist for the same key - Functional updates — Update parameters based on previous state without losing other params
- Stable reference —
setSearchParamsreference is stable and safe to use inuseEffect
Note:
getSearchParamsreturnsstringfor single values,string[]for multiple values, and''if the key is not found.
Lazy Loading
Clear Router supports code-splitting out of the box. Simply wrap dynamic import into a library's lazy function:
import { lazy } from 'clear-react-router';
{
path: '/heavy-page',
element: lazy(() => import('./pages/HeavyComponent')),
fallback: () => <div>Loading...</div>,
}Deferred data (React 19 use())
loader can return a value containing unresolved promises — the router doesn't await
anything beyond what your loader itself awaits. This lets you unblock navigation on
fast data while slow data streams in separately:
{
path: '/about',
loader: async () => {
const fast = await fetchFast();
const slow = fetchSlow(); // not awaited — passed through as a promise
return { fast, slow };
},
}const Slow = () => {
const { data } = useLoaderState<{ slow: Promise<string> }>();
return use(data.slow);
};
const About = () => (
<Suspense fallback={<Skeleton />}>
<Slow />
</Suspense>
);Wrap deferred parts in their own
ErrorBoundary— a rejected promise passed touse()is not caught by the route'serrorElement. Also setstaleTimegenerously enough to outlast your slowest deferred request, or a fast repeat visit may trigger a redundant refetch while the previous one is still resolving.
Animations
Clear Router supports smooth page transitions using the native View Transitions API. When animations are enabled, the router waits for all data to load before starting the transition, ensuring a jank-free experience.
How It Works
- Data loads first — All
loaderandbeforeLoadhooks complete before animation starts - Native API — Uses
document.startViewTransitionfor smooth, hardware-accelerated animations
Browser Support
View Transitions API requires modern browsers:
- Chrome/Edge 111+
- Safari 18+
- Firefox 144+
For older browsers, the router gracefully falls back to regular navigation without animation.
Requirements
- React 16.8+ (for React.lazy and Suspense)
- Use
defaultexport for your lazy-loaded components
Playground
Try it live — no setup: clear-router playground.
Scroll restoration (window + named containers), retry with attempt counts, live polling, actions with invalidation, navigation blocking, hover prefetch, optimistic navigation, LRU eviction with a tiny cache, gcTime cleanup, an invalidation lab, search params, and a context-based auth guard — all running against a fake in-browser server. Open devtools and click around.
License
MIT
