State - greydragon888/real-router GitHub Wiki

State

Overview

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.

Type Definition

interface State<P extends Params = Params> {
  name: string;
  params: P;
  path: string;
  transition: TransitionMeta;
  context: StateContext & Record<string, unknown>;
}

Properties

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

State Context

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 Interface

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.navigation has a known shape)
  • Without augmentation: any string key is allowed (for inline plugins, tests, or plugins that skip augmentation)

Module Augmentation Pattern

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
});

Reading Context in Components

// 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>
  );
}

Built-in Context Namespaces

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

Related Types

Params

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.

TransitionMeta

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: ""
//   }
// }

Distinguishing Route Entry from Parameter Change

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.from is undefined, so from !== state.name is true — 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.

SimpleState

A simplified state without path, used for creating states:

interface SimpleState<P extends Params = Params> {
  name: string;
  params: P;
}

Usage Examples

Accessing Current State

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"

With React

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>
  );
}

Type-Safe Parameters

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
});

UNKNOWN_ROUTE State

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.

State Lifecycle

  1. Creation: States are created via makeState() or buildState()
  2. Validation: Route existence is verified
  3. Path Building: URL path is computed from route pattern and params
  4. Context Initialization: context is set to {} (empty object)
  5. Metadata: Navigation ID and param source info are stored internally (not on the State object)
  6. Freezing: State object is shallow-frozen for immutability (context remains mutable for plugins)
  7. Plugin Writes: Plugins write to state.context.<namespace> during lifecycle hooks
  8. Storage: State becomes the router's current state after successful navigation

Important Notes

  • State objects are shallow-frozen via Object.freeze -- name, params, path, and transition are immutable
  • The context object is intentionally not frozen so plugins can write to it without cloning the state
  • The name property uses dot notation for nested routes
  • The path property is the computed URL, not the route pattern

Related Methods

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
⚠️ **GitHub.com Fallback** ⚠️