Guide

Nested Routing

Map URL structure onto nested State configuration without coupling paths to topology.

Nested routing is a composition between URL structure and State ownership. It is not a second Statechart language: @motive/motive-router lowers its small route vocabulary into ordinary Motive transitions through the public extension boundary.

Keep paths and State topology independent

The example deliberately proves both directions of that independence. /concerts/mine selects the sibling MyConcerts State even though the pathname has a shared prefix. /login selects the nested Auth.Login configuration even though the pathname has no /auth segment.

That supports nested URLs without forcing layout nesting and pathless layout States without adding segments to the URL.

Mount a child router at its owning State

mount("/account", Account) enters the State that owns the account child. Once active, the ordinary spawn(AccountOutlet) declaration starts that child and Routes.outlet(AccountOutlet) forwards the remaining pathname. Leaving Account retires the child through normal State-scoped lifecycle.

The whole route tree is not started in advance, and no synthetic inactive router State is needed.

Read the complete compiled example

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Routes } from "@motive/motive-router";

class AccountRouter extends State.Compound<AccountRouter>()("AccountRouter") {
  static states = States.make(() => [AccountIndex, AccountProfile]);

  static transitions = Transitions.make(this, ({ extend }) => [
    extend(
      Routes.make(({ bubble, route }) => [
        route("/", AccountIndex),
        route("/profile", AccountProfile),
        bubble(),
      ]),
    ),
  ]);
}

class AccountIndex extends State.Atomic<AccountIndex>()("AccountIndex") {}
class AccountProfile extends State.Atomic<AccountProfile>()("AccountProfile") {}

const AccountChart = Statechart.make(AccountRouter);
const AccountOutlet = AccountChart.as("AccountOutlet");

class WebApp extends State.Compound<WebApp>()("WebApp") {
  static states = States.make(() => [Home, Concerts, MyConcerts, Auth, Account]);

  static transitions = Transitions.make(this, ({ extend }) => [
    extend(
      Routes.make(({ mount, prefix, route }) => [
        route("/", Home),
        prefix("/concerts", ({ route }) => [route("/", Concerts), route("/mine", MyConcerts)]),
        route("/login", Login),
        mount("/account", Account),
      ]),
    ),
  ]);
}

class Home extends State.Atomic<Home>()("Home") {}
class Concerts extends State.Atomic<Concerts>()("Concerts") {}
class MyConcerts extends State.Atomic<MyConcerts>()("MyConcerts") {}

class Auth extends State.Compound<Auth>()("Auth") {
  static states = States.make(() => [Login]);
}

class Login extends State.Atomic<Login>()("Login") {}

class Account extends State.Atomic<Account>()("Account") {
  static transitions = Transitions.make(this, ({ extend, spawn }) => [
    spawn(AccountOutlet),
    extend(Routes.outlet(AccountOutlet)),
  ]);
}

export const WebAppChart = Statechart.make(WebApp);

The child router's final bubble() lets an unhandled application navigation return to its parent. Observed browser locations use the same nested resolution protocol but remain structurally distinct from application intent, so resolving an initial load or history traversal cannot authorize another push or replace operation.

Treat the URL as more than a pathname

Every navigation location also preserves an ordered query multimap and a fragment. The router does not flatten repeated query keys into a record or assume that every parameter belongs to the leaf route. A reporting application can therefore bind filters to the root, an intermediate stem, or a leaf according to the State that owns each value.

The binding grammar is intentionally separate from pathname matching: an observed URL is a complete external fact, while an application navigation may explicitly choose to inherit session-scoped filters. That distinction keeps persistent filters and deep links as two views of one State model instead of two models that must be synchronized by convention.