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
- Loading data whenever the route is the navigation target (entry OR param change) — this is the most common pattern, use
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). AddonEnteroronStayfor extra case-specific logic — they fire alongsideonNavigate, 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
- Plugin Architecture — How plugins integrate with the router
- subscribe — Global transition subscription (alternative approach)
- leave — Global leave subscription
- Route — Route configuration reference
- Navigation Lifecycle — Full transition event sequence
- @real-router/logger-plugin — Development logging plugin
- @real-router/browser-plugin — Browser History API plugin