State - greydragon888/real-router GitHub Wiki
The State interface represents the current navigation state of the router. It contains information about the active route, its parameters, and the computed URL path.
interface State<P extends Params = Params> {
name: string;
params: P;
path: string;
transition: TransitionMeta;
context: StateContext & Record<string, unknown>;
}| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Route name (dot-separated for nested routes, e.g., "users.profile") |
params |
P extends Params |
Yes | Route parameters extracted from URL and query string |
path |
string |
Yes | Computed URL path for this state |
transition |
TransitionMeta |
Yes | Transition details after successful navigation (deeply frozen) |
context |
StateContext & Record<string, unknown> |
Yes | Plugin-extensible per-state data, populated via claim-based API |
The context field is a required property on every State object. It is always present as at least {} (empty object). Plugins attach per-navigation data to it via the claimContextNamespace API.
Unlike name, params, path, and transition which are frozen via Object.freeze(state), the context object itself is not frozen. This is intentional -- plugins can attach data to it without cloning the entire state object.
StateContext is an empty interface that plugins extend via module augmentation:
// Empty at baseline — extended by plugins
interface StateContext {}The State.context type is StateContext & Record<string, unknown>, which means:
-
With augmentation: typed namespaces (e.g.,
state.context.navigationhas a known shape) - Without augmentation: any string key is allowed (for inline plugins, tests, or plugins that skip augmentation)
Each plugin that writes to state.context declares its namespace via declare module "@real-router/types":
// In @real-router/navigation-plugin
declare module "@real-router/types" {
interface StateContext {
navigation?: NavigationMeta;
}
}
// In @real-router/browser-plugin
declare module "@real-router/types" {
interface StateContext {
browser?: BrowserContext;
}
}
// In @real-router/memory-plugin
declare module "@real-router/types" {
interface StateContext {
memory?: MemoryContext;
}
}
// In @real-router/persistent-params-plugin
declare module "@real-router/types" {
interface StateContext {
persistentParams?: Params;
}
}
// In @real-router/ssr-data-plugin
declare module "@real-router/types" {
interface StateContext {
data?: unknown;
}
}Import the plugin package to activate the augmentation -- after that, state.context.<namespace> is type-safe:
import "@real-router/navigation-plugin";
import "@real-router/memory-plugin";
router.subscribe((state) => {
state.context.navigation?.direction; // "forward" | "back" | "unknown" -- typed
state.context.memory?.historyIndex; // number -- typed
});// React example
import { useRoute } from "@real-router/react";
import "@real-router/navigation-plugin"; // activate type augmentation
function TransitionInfo() {
const { route } = useRoute();
const navContext = route?.context.navigation;
if (!navContext) return null;
return (
<div>
<p>Navigation type: {navContext.navigationType}</p>
<p>Direction: {navContext.direction}</p>
<p>User initiated: {navContext.userInitiated ? "yes" : "no"}</p>
</div>
);
}| Namespace | Plugin | Type | Description |
|---|---|---|---|
navigation |
@real-router/navigation-plugin |
NavigationMeta (navigationType, userInitiated, direction, sourceElement, info?) |
Navigation API metadata |
browser |
@real-router/browser-plugin |
BrowserContext ({ source: "popstate" | "navigate" }) |
Whether navigation came from popstate or code |
memory |
@real-router/memory-plugin |
MemoryContext ({ direction, historyIndex }) |
Memory history direction and index |
persistentParams |
@real-router/persistent-params-plugin |
Params |
Snapshot of current persistent parameters |
data |
@real-router/ssr-data-plugin |
unknown |
Loader result from SSR data loading |
The base type for route parameters:
interface Params {
[key: string]:
| string
| string[]
| number
| number[]
| boolean
| boolean[]
| Params
| Params[]
| Record<string, string | number | boolean>
| null
| undefined;
}state.params never contains undefined values. After router.navigate(name, { a: 1, b: undefined }), reading state.params gives { a: 1 } — the "b" key is absent ("b" in state.params === false). This keeps state.params consistent with the resulting URL: if the parameter is not in the URL, it is not in state.params either.
This contract is enforced at the @real-router/core boundary via normalizeParams. It applies to user-provided params and to params added by plugin interceptors on forwardState.
| Value in input |
state.params after navigation |
|---|---|
undefined |
key absent |
null |
null (present) |
"" |
"" (present, empty string) |
0, false
|
preserved (falsy-defined) |
See Params Contract in core README for the full type → URL → state.params mapping.
Metadata about the last transition, set after every successful navigation:
interface TransitionMeta {
phase: TransitionPhase; // "deactivating" | "activating"
reason: TransitionReason; // "success" | "blocked" | "cancelled" | "error"
reload?: boolean; // true after navigate(..., { reload: true })
redirected?: boolean; // true if navigation was redirected via forwardTo
from?: string; // Previous route name (undefined on first navigation)
blocker?: string; // Reserved — not yet populated by core
segments: {
deactivated: string[]; // Route segments that were deactivated (frozen)
activated: string[]; // Route segments that were activated (frozen)
intersection: string; // Common ancestor segment
};
}The transition field and its nested objects are deeply frozen. Transition timing is available via @real-router/logger-plugin.
const state = await router.navigate("users.profile", { id: "123" });
console.log(state.transition);
// {
// phase: "activating",
// from: "home",
// reason: "success",
// segments: {
// deactivated: ["home"],
// activated: ["users", "users.profile"],
// intersection: ""
// }
// }Compare transition.from with state.name to determine whether the user entered the route or stayed on it with different parameters:
router.subscribe((state) => {
const { transition } = state;
if (transition.from !== state.name) {
// Route entry — user came from a different route (or this is the first navigation)
// Full initialization: reset scroll, fetch initial data
} else {
// Parameter change — same route, different params
// Incremental update: re-fetch with new filters/sorting
}
});Real-world example — a table with filters and sorting:
const routes = [
{ name: "services.catalog", path: "/catalog?q&sort&dir" },
];
router.subscribe((state) => {
if (state.name !== "services.catalog") return;
if (state.transition.from !== "services.catalog") {
// Entered from another route — full page setup
initScrollPosition();
loadServices(state.params);
} else {
// Filters or sorting changed — just update the table
loadServices(state.params);
}
});Note: On the first navigation (router start),
transition.fromisundefined, sofrom !== state.nameistrue— correctly treated as entry.To detect explicit reloads separately, check
transition.reload:if (transition.reload) { /* forced reload */ } else if (transition.from !== state.name) { /* route entry */ } else { /* parameter change */ }
The @real-router/lifecycle-plugin uses the same logic internally: onEnter fires when the route name changes, onStay fires when only parameters change, and onNavigate fires for both cases as a declarative fallback — so you rarely need this subscribe() boilerplate in real apps.
A simplified state without path, used for creating states:
interface SimpleState<P extends Params = Params> {
name: string;
params: P;
}import { createRouter } from "@real-router/core";
const router = createRouter(routes);
await router.start("/users/123");
const state = router.getState();
console.log(state.name); // "users.profile"
console.log(state.params); // { id: "123" }
console.log(state.path); // "/users/123"import { useRoute } from "@real-router/react";
function CurrentPage() {
const { route } = useRoute();
if (!route) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Current Route: {route.name}</h1>
<p>Path: {route.path}</p>
<pre>{JSON.stringify(route.params, null, 2)}</pre>
</div>
);
}import type { State } from "@real-router/core";
interface UserParams {
id: string;
tab?: string;
}
// State objects are created by the router, not manually.
// Use the generic parameter for type-safe access:
router.subscribe((state: State<UserParams>) => {
state.params.id; // string
state.params.tab; // string | undefined
});When navigateToNotFound() is called or start() encounters an unmatched path with allowNotFound: true,
the state has a special shape:
| Property | Value | Description |
|---|---|---|
name |
"@@router/UNKNOWN_ROUTE" |
Special constant, import as UNKNOWN_ROUTE
|
params |
{} |
Always empty |
path |
"/failed/url" |
The URL that led to 404 |
transition.from |
"previous.route" |
Previous route name (if any) |
transition.segments.deactivated |
["previous.route", "previous"] |
Segments that were left |
transition.segments.activated |
["@@router/UNKNOWN_ROUTE"] |
Always single-element |
import { UNKNOWN_ROUTE } from "@real-router/core";
const state = router.getState();
if (state?.name === UNKNOWN_ROUTE) {
console.log(state.path); // "/items/10000" — the URL that failed
console.log(state.params); // {} — always empty
console.log(state.transition.from); // "items.view" — where the user was
}See navigateToNotFound for full documentation.
-
Creation: States are created via
makeState()orbuildState() - Validation: Route existence is verified
- Path Building: URL path is computed from route pattern and params
-
Context Initialization:
contextis set to{}(empty object) - Metadata: Navigation ID and param source info are stored internally (not on the State object)
-
Freezing: State object is shallow-frozen for immutability (
contextremains mutable for plugins) -
Plugin Writes: Plugins write to
state.context.<namespace>during lifecycle hooks - Storage: State becomes the router's current state after successful navigation
- State objects are shallow-frozen via
Object.freeze--name,params,path, andtransitionare immutable - The
contextobject is intentionally not frozen so plugins can write to it without cloning the state - The
nameproperty uses dot notation for nested routes - The
pathproperty is the computed URL, not the route pattern
| Method | Description |
|---|---|
getState() |
Get current router state |
getPreviousState() |
Get previous router state |
makeState() |
Create state object from name and params |
buildState() |
Build partial state for navigation |
areStatesEqual() |
Compare two states for equality |