A modern, type-safe React router with code splitting, data preloading, and Suspense integration
A modern, type-safe React router built with TypeScript that supports code splitting, data preloading, and React Suspense integration.
- Type-safe routing with full TypeScript support
- Code splitting with dynamic imports and lazy loading
- Data preloading for faster navigation
- React Suspense integration for smooth loading states
- Browser history management with push/replace state
- Nested routing support
- Route-based redirects
- Active link detection with exact/partial matching
``bash`
npm install @plumile/router
This package expects react and react-dom to be available in your project
(React 18 or 19).
`bash`
npm install react react-dom
- @plumile/router: primary ESM entry exporting runtime APIs and types.@plumile/router/lib/esm/*
- : direct file imports when you need to tree-shake specific helpers.@plumile/router/lib/types/*
- : TypeScript declaration files (consumed automatically via types field).
This package is ESM-only. If your tooling expects CommonJS, enable ESM support (e.g. Vite, Next.js, or webpack with type: 'module').
- Getting started: User guide
- Relay integration walkthrough: Relay guide
- Examples: Examples
- Migration strategies: Migration guide
- DevTools extension usage: DevTools documentation
`typescript
import { Route, getResourcePage } from '@plumile/router';
const routes: Route
{
path: '/',
resourcePage: getResourcePage('Home', () => import('./pages/Home')),
},
{
path: '/about',
resourcePage: getResourcePage('About', () => import('./pages/About')),
},
{
path: '/users',
children: [
{
path: '/:id',
resourcePage: getResourcePage(
'UserProfile',
() => import('./pages/UserProfile'),
),
prepare: ({ variables }) => {
// Preload user data
return { userId: variables.id };
},
},
],
},
];
`
`typescript
import { createRouter } from '@plumile/router';
export const router = createRouter(routes);
const { context, cleanup } = router;
// Call cleanup() when tearing the app down (tests, SSR shell disposal, etc.).
`
`tsx
import React from 'react';
import { RoutingContext, RouterRenderer } from '@plumile/router';
import { router } from './router';
const { context } = router;
function App() {
return (
$3
`tsx
import { Link } from '@plumile/router';function Navigation() {
return (
);
}
`Hooks Overview
| Hook | Signature | Purpose | Example |
| ------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
useNavigate() | () => Navigate | Imperative navigation with type-safe params and filters. | const navigate = useNavigate(); navigate({ pathname: '/products', filters: { page: { eq: 2 } } }); |
| useFilters(schema) | (schema) => [filters, actions] | Read and mutate filters inferred from a schema. | const [filters, actions] = useFilters(productFilters); actions.set('price', 'gt', 10); |
| useQuery() | () => Record | Access the raw query aggregation (legacy/simple use). | const query = useQuery(); |
| useLocation() | () => Location | Read the current location object. | const location = useLocation(); |
| usePathname() | () => string | Read the current pathname. | const pathname = usePathname(); |
| useSearchParams() | () => SearchParamsActions | Read and update URLSearchParams. | const { params, setParam } = useSearchParams(); |
| useQueryState(key, options?) | (key, options?) => [value, setValue] | Two-way binding for a single query parameter with default/replace options. | const [page, setPage] = useQueryState('page', { defaultValue: 1 }); |
| useFilterDiagnostics() | () => Diagnostic[] | Surface parsing issues (unknown fields/operators) for UI or logging. | const diagnostics = useFilterDiagnostics(); |
| useAllQuery(options?) | (options?) => QueryLike | Merge filters and raw query, helpful during migrations. | const all = useAllQuery(); |API Reference
$3
####
createRouterCreates a router instance with the given route configuration.
Parameters:
-
routes: Array of route definitions
- options?: Optional configuration
- context?: Static context value or lazy initializer
- getContext?: Resolve a fresh context value per navigation
- instrumentations?: Instrumentations invoked on router eventsReturns:
-
context: Router context object for the React Context Provider
- cleanup: Function to clean up router listeners####
RouterRendererRenders the matched route component with Suspense support.
Props:
-
fallback?: ReactNode - Fallback UI while loading components
- enableTransition?: boolean - Enable React 18 transitions
- pending?: ReactNode - UI to show during transitions####
LinkNavigation component that handles client-side routing.
Props:
-
to: string | HistoryLocation - Destination path (supports search/hash)
- filters?: object - Filters object serialized with the active query schema
- query?: object - Raw query params merged as non-schema keys
- exact?: boolean - Exact path matching for active state
- activeClassName?: string - CSS class when link is active
- className?: string - Base CSS class
- preloadOnMouseEnter?: boolean - Preload route on hover
- preloadOnMouseDown?: boolean - Preload route on mouse down
- href?: string - Explicit href override
- target?: string - Target attribute for the link
- onClick?: (event) => void - Click handler####
RoutingContextReact context that provides router functionality to components.
$3
####
RouteRoute definition interface.
Properties:
-
path?: string - URL path pattern
- children?: Route[] | Redirect[] - Nested routes
- resourcePage?: ResourcePage - Lazy-loaded component
- prepare?: Function to preload data (receives context)
- render?: Custom render function (receives context)####
RedirectRedirect configuration.
Properties:
-
path?: string - Source path
- to: string - Destination path
- status?: 301 | 302 - HTTP status code$3
####
getResourcePage(moduleId: string, loader: ResourcePageLoader)Creates a resource for lazy-loading components.
Parameters:
-
moduleId: Unique identifier for caching
- loader: Function that returns dynamic importReturns:
-
ResourcePage instance####
ResourcePageManages lazy-loaded components with Suspense integration.
Methods:
-
load(): Promise - Load the component
- get(): Component | undefined - Get loaded component
- read(): Component - Read with Suspense (throws Promise if loading)$3
####
BrowserHistoryBrowser history implementation.
Methods:
-
push(location): Navigate to new location
- set(location): Replace current location
- subscribe(listener): Listen for navigation changes$3
####
getMatchedRoute(routes, location)Finds the matching route for a given location.
####
prepareMatch(match, query?, instrumentation?, context?)Prepares route data and components for rendering.
####
rType helper for strongly-typed route definitions.
Unified Query & Filter Model
The router now unifies page-like query parameters and structured filters under a single model powered by
@plumile/filter-query.Key points:
- A route can declare a
querySchema (filter schema) on its deepest branch.
- All URL parameters (simple key=value and filter operators like price.gt=10) are parsed into a single filters object.
- Equality is implicit: field=value maps to internal operator eq.
- The current active schema is exposed as entry.activeQuerySchema for tooling.
- Serialization is centralized via buildCombinedSearch (used by both navigate and Link).$3
`ts
import { r } from '@plumile/router';
import { defineSchema, numberField, stringField } from '@plumile/filter-query';const productFilters = defineSchema({
page: numberField(),
price: numberField(),
title: stringField(),
});
const routes = [
r({
path: '/products',
querySchema: productFilters,
prepare: ({ filters }) => ({ page: filters.page?.eq ?? 1 }),
render: () => null,
}),
];
`$3
useFilters now requires the schema as a mandatory argument (dynamic discovery was removed). It returns a tuple [filters, actions] where filters is fully inferred from the provided schema and actions are strongly typed mutation helpers.`tsx
import { useFilters } from '@plumile/router';
import { productFilters } from './schemas'; // assume you exported the schema abovefunction List() {
const [filters, { set, remove, merge, clear }] = useFilters(productFilters);
// All operators/values are type‑checked against the schema
set('price', 'gt', 10); // => ?price.gt=10
set('page', 'eq', 2); // => ?page=2 (page normalization will clamp <1 to 1)
remove('price', 'gt'); // remove only that operator
merge({ price: { between: [10, 20] }, title: { contains: 'ultra' } });
clear(); // remove all active filters
return null;
}
`$3
`ts
context.navigate({ pathname: '/products', filters: { page: { eq: 3 } } });
`$3
The router clamps
page.eq < 1 to 1 synchronously and performs a history replace so the back stack is not polluted.$3
buildCombinedSearch produces a leading ? (or empty string) by:1. Ordering simple query params (from schema order).
2. Appending filter operator segments (
field.operator=value).
3. Using implicit equality (field=value).
4. Maintaining reference stability (no unnecessary object churn).$3
The legacy query DSL (
q.*, parseTypedQuery, buildSearch) has been removed. Replace any usage with a filter schema (defineSchema) and rely on useFilters, navigate({ filters }), and/or buildCombinedSearch.$3
Filters cache: repeated navigations with semantically identical search strings reuse the same filter object reference, enabling cheap React renders.
$3
`tsx
Deals
`Renders:
/products?page=1&price.gt=10.$3
If you need to build a URL outside of navigation (e.g. constructing a sitemap entry):
`ts
import { buildCombinedSearch } from '@plumile/router';
// or: import buildCombinedSearch from '@plumile/router/lib/esm/routing/tools/buildCombinedSearch.js';const search = buildCombinedSearch({
filters: { page: { eq: 1 }, price: { gt: 10 } },
querySchema: productFilters,
query: { ref: 'promo' },
});
// => '?page=1&price.gt=10&ref=promo'
`Guideline: If you need to derive lightweight projections (e.g.
const { page } = typed), you can still destructure; but avoid spreading into a new object if you rely on reference equality downstream.#### Inspection & Instrumentation
Activez l’instrumentation en développement pour exposer un bridge DevTools :
`ts
import {
createRouter,
createDevtoolsBridgeInstrumentation,
} from '@plumile/router';const devtools = createDevtoolsBridgeInstrumentation();
const { context } = createRouter(routes, { instrumentations: [devtools] });
`Aucun bridge n’est publié en production. Vous pouvez chaîner plusieurs instrumentations (par exemple la console) :
`ts
import {
createConsoleLoggerInstrumentation,
createDevtoolsBridgeInstrumentation,
} from '@plumile/router';const instrumentations = [
createDevtoolsBridgeInstrumentation(),
createConsoleLoggerInstrumentation({ label: 'router' }),
];
createRouter(routes, { instrumentations });
`Pour l’inspection visuelle (timeline, filtres, prepared data), installez la Plumile Router DevTools extension (voir docs/router-devtools-extension.md).
$3
The router uses a single unified schema (
querySchema) defined with @plumile/filter-query to describe allowed key/operator pairs. Plain field=value maps to the implicit equality operator (eq). Each filter segment is encoded as field.operator=value (with eq omitted).`ts
import { r, useFilters } from '@plumile/router';
import { defineSchema, numberField, stringField } from '@plumile/filter-query';const querySchema = defineSchema({
page: numberField(), // page.eq=2 => ?page=2 (page normalization clamps <1 to 1)
price: numberField(), // price.gt=10 => ?price.gt=10
name: stringField(), // name.contains=ultra => ?name.contains=ultra
});
export const routes = [
r({
path: '/items',
querySchema,
render: () => ,
}),
];
function Items() {
const [filters, { set, remove, clear, merge }] = useFilters(querySchema);
set('price', 'gt', 10); // => ?price.gt=10
set('page', 'eq', 2); // => ?page=2
remove('price', 'gt'); // remove only that operator
merge({ price: { between: [10, 20] } }); // descriptor-array form removed; use object patch
clear();
return null;
}
`navigate({ filters }) is available for programmatic updates. Serialization order: schema field keys first, then any extra non‑schema keys (if present). Operators follow deterministic ordering from @plumile/filter-query.#### Typed Actions Cheat‑Sheet
`ts
const [filters, a] = useFilters(querySchema);
a.set('price', 'in', [10, 20, 30]); // array operators allowed when schema supports it
a.set('title', 'contains', 'ultra');
a.remove('price', 'in'); // remove only that operator
a.merge({ page: { eq: 3 }, price: { gt: 50 } }); // deep merge per field
a.clear(); // wipe everything (noop if already empty)
`Navigation is skipped automatically if the structural filters object doesn't change (shallow compare) to avoid redundant history entries.
Diagnostics (unknown field/operator, invalid values, etc.) are accessible via
useFilterDiagnostics().$3
To encourage consistent usage of the query hooks, a custom rule is provided inside the router package to flag raw
window.location.search access.Add to your flat ESLint config:
`js
import noDirectWindowLocationSearch from '@plumile/router/lib/eslint-rules/no-direct-window-location-search.js';export default [
{
plugins: {
'@plumile-router/dx': {
rules: {
'no-direct-window-location-search': noDirectWindowLocationSearch,
},
},
},
rules: {
'@plumile-router/dx/no-direct-window-location-search': 'warn',
},
},
];
`Optional configuration:
`js
// allow some files (e.g. legacy bootstrap) to keep direct access
rules: {
'@plumile-router/dx/no-direct-window-location-search': [
'warn',
{ allowInFiles: ['legacy-entry.ts'] },
],
},
`When triggered, replace patterns like:
`ts
const qs = window.location.search;
`with:
`ts
const query = useQuery(); // raw key=value aggregation (simple); prefer useFilters() for structured access.
`$3
1. Remove legacy imports of
q, parseTypedQuery, buildSearch.
2. Define a schema with defineSchema from @plumile/filter-query and attach as querySchema on the route needing filters.
3. Replace (removed) useTypedQuery() usages with useFilters(querySchema) (schema argument now mandatory – breaking change vs earlier experimental auto‑discovery version).
4. Update navigation: navigate({ filters: { page: { eq: 2 } } }) (unchanged pattern).
5. Update links: .
6. Replace any previous merge([{ field, op, value }]) descriptor array calls with merge({ field: { op: value } }) object patches.
7. Page defaults / normalization: rely on built-in clamp (page < 1 -> 1).
8. Remove any type tests referencing the DSL; rely on schema inference from defineSchema.
9. If you manually used buildCombinedSearch({ schema }), rename the option to querySchema.$3
Removed in favor of the unified model:
-
q.* descriptor DSL (use defineSchema)
- parseTypedQuery
- buildSearch (use buildCombinedSearch)
- useTypedQueryChanged (breaking):
-
useFilters() → useFilters(schema) (schema argument required; no dynamic discovery)
- merge action signature: descriptor array → object patch (merge({ field: { operator: value } }))
- buildCombinedSearch({ schema }) → buildCombinedSearch({ querySchema })Use
@plumile/filter-query for schema definitions and useFilters(schema) / buildCombinedSearch for runtime access & serialization.$3
useQueryState(key, opts?) creates a controlled binding between a single query parameter and component state.`tsx
const [page, setPage] = useQueryState('page');
// Increment page without pushing a new history entry
setPage(page! + 1, { replace: true });
`Behavior:
- Reads from filters (implicit equality) and falls back to raw query key when not covered by schema.
- Allows
defaultValue override in options and omits key when value equals default (with omitIfDefault: true).
- Pass { raw: true } to force raw (string) source for incremental migrations.
- Uses unified serialization ordering (schema keys then extras; operators deterministic).Options:
{ defaultValue?, omitIfDefault?: boolean = true, replace?: boolean, raw?: boolean }$3
`typescript
const route: Route = {
path: '/users/:id',
prepare: async ({ variables }) => {
const user = await fetchUser(variables.id);
return { user };
},
render: ({ prepared, children }) => {
if (!prepared) return null;
return {children} ;
},
};
`$3
`typescript
const route: Route = {
path: '/protected',
render: ({ children, prepared }) => {
if (!userIsAuthenticated()) {
return ;
}
return {children} ;
},
};
`$3
`tsx
import { useContext } from 'react';
import { RoutingContext } from '@plumile/router';function MyComponent() {
const router = useContext(RoutingContext);
const handleClick = () => {
router.history.push({ pathname: '/new-path' });
};
return ;
}
`$3
`tsx
import { useContext } from 'react';
import { RoutingContext } from '@plumile/router';function MyComponent() {
const router = useContext(RoutingContext);
const handleHover = () => {
// Preload code only
router.preloadCode({ pathname: '/users/123' });
// Preload code and data
router.preload({ pathname: '/users/123' });
};
return (
User Profile
);
}
`TypeScript Support
The router is built with TypeScript and provides full type safety:
`typescript
import { Route, r } from '@plumile/router';interface UserPageData {
user: User;
posts: Post[];
}
interface UserPageParams {
id: string;
}
const userRoute = r({
path: '/users/:id',
prepare: ({ variables }) => {
// variables.id is typed as string
return fetchUserData(variables.id);
},
render: ({ prepared }) => {
// prepared is typed as UserPageData | undefined
if (!prepared) return null;
return ;
},
});
``- Modern browsers that support ES2021
- React 18+
- Node.js 21+
Licensed under the terms specified in the package's LICENSE file.