lifecycle plugin - greydragon888/real-router GitHub Wiki

@real-router/lifecycle-plugin

1. Overview

  • Name: Lifecycle Plugin
  • Package: @real-router/lifecycle-plugin
  • Purpose: Route-level lifecycle hooks — onNavigate, onEnter, onStay, onLeave — declarative side-effects attached directly to route definitions.
  • Typical scenarios:
    • Loading data whenever the route is the navigation target (entry OR param change) — this is the most common pattern, use onNavigate
    • Analytics tracking when entering a route
    • Cleaning up timers, WebSockets, or draft state when leaving a route
    • Refreshing data when route params change (same route, different params)
    • Prefetching data on route entry

Start with onNavigate. It covers the most common case — running the same logic whenever the route is the navigation target (data loading, analytics, UI reset). Add onEnter or onStay for extra case-specific logic — they fire alongside onNavigate, not instead of it.

2. Installation and Setup

npm install @real-router/lifecycle-plugin
# or
pnpm add @real-router/lifecycle-plugin
import { createRouter } from "@real-router/core";
import { lifecyclePluginFactory } from "@real-router/lifecycle-plugin";

const routes = [
  {
    // Recommended default — same logic on entry and param-change
    name: "services.catalog",
    path: "/catalog?q&sort&dir",
    onNavigate: () => (toState) => loadServices(toState.params),
  },
  {
    name: "dashboard",
    path: "/dashboard",
    onEnter: () => () => analytics.track("dashboard_viewed"),
    onLeave: () => () => autosaveTimer.clear(),
  },
  {
    name: "users.view",
    path: "/users/:id",
    onStay: () => (toState, fromState) => {
      console.log("User changed:", fromState.params.id, "→", toState.params.id);
    },
  },
];

const router = createRouter(routes);
router.usePlugin(lifecyclePluginFactory());

await router.start("/dashboard");

3. Configuration

The plugin takes no configuration. The hooks themselves — defined on route objects — are the configuration.

Types

type LifecycleHook = (toState: State, fromState: State | undefined) => void;
type LifecycleHookFactory<Dependencies> = (router: Router<Dependencies>, getDependency: GetDependency<Dependencies>) => LifecycleHook;

Route fields (onNavigate, onEnter, onStay, onLeave) accept LifecycleHookFactory — a factory that receives the router and DI getter. The returned LifecycleHook is cached per route. Return values are ignored.

Module Augmentation

Importing the plugin package extends the Route interface with typed fields:

// Automatically available after importing the plugin
interface Route<Dependencies> {
  onNavigate?: LifecycleHookFactory<Dependencies>;
  onEnter?: LifecycleHookFactory<Dependencies>;
  onStay?: LifecycleHookFactory<Dependencies>;
  onLeave?: LifecycleHookFactory<Dependencies>;
}

Each hook field is a factory function (router, getDependency) => LifecycleHook. The factory runs once per route; the returned hook is cached and reused. When you don't need DI, omit the factory params:

// Without DI:
onEnter: () => (toState) => { console.log("entered", toState.name); }

// With DI:
onEnter: (router, getDependency) => (toState) => {
  const api = getDependency("api");
  api.trackPageView(toState.name);
}

4. Lifecycle Hooks

Plugin Hook Implemented Description
onStart
onStop
onTransitionStart
onTransitionLeaveApprove Calls onLeave on the leaving route
onTransitionSuccess Calls onEnter / onStay on the target route, plus onNavigate (orthogonal)
onTransitionError
onTransitionCancel
teardown Stateless — no cleanup needed

Route-Level Hook Semantics

Hook Fires when Router event Route checked
onNavigate Any successful navigation to the route TRANSITION_SUCCESS toState.name
onEnter Route is entered TRANSITION_SUCCESS toState.name
onStay Same route, params changed TRANSITION_SUCCESS toState.name
onLeave Route is left TRANSITION_LEAVE_APPROVE fromState.name

Orthogonal dispatch

onEnter / onStay / onNavigate fire independently based on their own conditions — they do not override each other. On entry, both onEnter and onNavigate fire. On same-route param change, both onStay and onNavigate fire. Each hook is composable.

{
  name: "chat",
  path: "/chat/:roomId",
  onEnter: () => () => connectWebSocket(),   // entry-only setup
  onNavigate: () => () => loadMessages(),    // every navigation (including entry)
}
// Entry:        onEnter fires, onNavigate also fires
// Param change: onStay absent, onNavigate fires

This matches how global plugin hooks work (onTransitionStart and onTransitionSuccess coexist) and removes the need to duplicate shared logic inside case-specific hooks.

Execution Order

navigate("newRoute")
    │
    ├── deactivation guards pass
    │
    ├── TRANSITION_LEAVE_APPROVE
    │   └── onLeave(toState, fromState) on fromState.name
    │
    ├── activation guards pass
    │
    └── TRANSITION_SUCCESS
        └── onEnter(toState, fromState) on toState.name

For same-route navigation (params change):

navigate("sameRoute", { id: "2" })
    │
    └── TRANSITION_SUCCESS
        └── onStay(toState, fromState) on toState.name

Note: onLeave does not fire for same-route navigation.

5. Router Interaction

Router Interface Extension

The plugin does not extend the Router interface. It's a purely observational plugin.

Hook Resolution

Hooks are read from route custom fields via api.getRouteConfig(routeName). Custom fields are any route properties beyond the standard set (name, path, children, canActivate, canDeactivate, forwardTo, encodeParams, decodeParams, defaultParams).

Data Flow

Route definition: { name: "home", path: "/", onEnter: factory }
    │
    ├── Core extracts custom fields → routeCustomFields["home"] = { onEnter: factory }
    │
    └── Plugin reads via api.getRouteConfig("home")?.onEnter
        ├── typeof === "function" → compile: hook = factory(router, getDependency)
        ├── Cache compiled hook in compiledHooks Map
        └── Call hook(toState, fromState)

6. Behavior

Leaf Route Only

Hooks fire only for the leaf route (toState.name / fromState.name), not for parent segments. Navigation from home to users.view calls onLeave on home and onEnter on users.view — not on users.

Error Handling

Errors from hooks propagate to the EventEmitter, which logs them to stderr. The router continues operating — transitions are never blocked by hook errors.

Edge Cases

Scenario Behavior
Route without hooks No-op, no errors
Non-function value in hook field Silently skipped (typeof check)
Initial router.start() onEnter fires with fromState = undefined
onLeave on initial start Does not fire (no previous route)
Hook throws an error Error logged to stderr, transition continues
onLeave + transition fails at activation onLeave already fired (leave-approve phase)

7. Integration with Other Plugins

Plugin Interaction
@real-router/browser-plugin Works correctly, hooks fire on popstate too
@real-router/logger-plugin Logger logs transition, lifecycle hooks also fire
@real-router/persistent-params-plugin Persistent params visible in hook's toState

Registration Order

No ordering constraints. The plugin observes events — it doesn't modify state.

router.usePlugin(lifecyclePluginFactory()); // Any position
router.usePlugin(browserPluginFactory());
router.usePlugin(loggerPluginFactory());

8. Usage Examples

Data Loading on Navigation (recommended default)

Most routes need the same side-effect on entry and on param-change — use onNavigate:

const routes = [
  {
    name: "services.catalog",
    path: "/catalog?q&sort&dir",
    onNavigate: () => (toState) => {
      // Fires on entry AND on filter/sort/pagination changes
      loadServices(toState.params);
    },
  },
];

Analytics Tracking

const routes = [
  {
    name: "product",
    path: "/products/:id",
    onEnter: () => (toState) => {
      analytics.track("product_viewed", { productId: toState.params.id });
    },
  },
];

Cleanup on Leave

const routes = [
  {
    name: "editor",
    path: "/editor/:docId",
    onLeave: () => () => {
      autosaveTimer.clear();
      webSocket.disconnect();
    },
  },
];

React to Param Changes

const routes = [
  {
    name: "users.view",
    path: "/users/:id",
    onStay: () => (toState) => {
      userStore.loadUser(toState.params.id);
    },
  },
];

Combined Hooks

const routes = [
  {
    name: "chat",
    path: "/chat/:roomId",
    onEnter: () => (toState) => {
      chatSocket.connect(toState.params.roomId);
    },
    onStay: () => (toState, fromState) => {
      chatSocket.switchRoom(fromState.params.roomId, toState.params.roomId);
    },
    onLeave: () => () => {
      chatSocket.disconnect();
    },
  },
];

Hybrid: onNavigate + onEnter (orthogonal)

Share common logic via onNavigate, add entry-only setup via onEnter. Both fire on entry (orthogonal dispatch); on param-change, only onNavigate fires because onStay is not defined.

const routes = [
  {
    name: "chat",
    path: "/chat/:roomId",
    onEnter: () => (toState) => chatSocket.connect(toState.params.roomId), // entry-only setup
    onNavigate: () => (toState) => loadMessages(toState.params.roomId),    // every navigation, incl. entry
    onLeave: () => () => chatSocket.disconnect(),
  },
];

See Also