Lesson 02 / 22
Nested Routes and Layouts
Defining routes as a tree; layout components and the outlet, the index route, the active layout chain produced from a match, the shell preserved during navigation, and handling parameter changes separately.
Contents
The previous lesson’s matcher worked on a flat table: each path bound to a single view. The North Slope Measurement Station application’s screens, though, share a common shell. The station detail view and the measurement history show the same header, the same side navigation, and the same station profile; only the middle region changes.
With a flat table, this shared shell has to be rebuilt in every route’s component. The result is three separate defects: the same markup repeats in multiple places, the shell repaints on every navigation, and state inside the shell — an open menu, a scrolled list — resets on every navigation. This lesson solves all three by defining routes as a tree.
The Route Tree
An address’s path is already hierarchical. /station/north-slope/measurements is made
of three segments, and each one narrows down the one before it. The route definition
can be written the same way, nested: each node takes on one segment and hands the
remaining segments to the nodes below it.
Nodes in the tree serve two purposes. A layout component node builds the surroundings for every view beneath it: header, navigation, frame. A leaf node fills in the part of the screen that changes. A layout component declares where the child view goes with an outlet. This is routing’s counterpart to the slot concept defined in the Templates and Slots lesson in the Web Components and Offline topic: the content comes from outside, and the container says where to place it.
A node’s own path is written relative. The :id node sits beneath the station
node, so its full path becomes /station/:id; the definition never repeats this
prefix. This way, when an entire subtree moves elsewhere, the definitions inside it
do not change.
A third kind of node is needed. When the path /station is requested, the
StationShell layout matches, but there is no child view to place in the outlet. An
index route fills this gap: a child with no segment of its own that matches once
the path is exhausted. The index route is the layout’s default content.
From Match to Chain
Matching against the tree proceeds root to leaf, consuming segments along the way. The result is not a single component but a layout chain running from root to leaf. On screen, this chain nests inside out: each component hosts the next one in its own outlet.
// route-tree.mjs — producing the active layout chain from a nested route tree const tree = { path: "", component: "Shell", children: [ { index: true, component: "Welcome" }, { path: "session", component: "SignIn" }, { path: "station", component: "StationShell", children: [ { index: true, component: "StationList" }, { path: "new", component: "StationForm" }, { path: ":id", component: "StationProfile", children: [ { index: true, component: "StationSummary" }, { path: "measurements", component: "MeasurementHistory" }, { path: "settings", component: "StationSettings" }, ], }, ], }, { path: "*", component: "NotFound" }, ], }; const ORDER = (d) => d.index ? 0 : d.path === "*" ? 3 : d.path.startsWith(":") ? 2 : 1; function resolve(node, remaining, chain, params) { if (node.index) return remaining.length === 0 ? { chain: [...chain, node.component], params } : null; let consumed = 0, p = params; if (node.path === "*") { p = { ...p, rest: remaining.join("/") }; consumed = remaining.length; } else if (node.path !== "") { if (remaining.length === 0) return null; if (node.path.startsWith(":")) p = { ...p, [node.path.slice(1)]: decodeURIComponent(remaining[0]) }; else if (node.path !== remaining[0]) return null; consumed = 1; } const newChain = [...chain, node.component]; const newRemaining = remaining.slice(consumed); for (const child of [...(node.children ?? [])].sort((a, b) => ORDER(a) - ORDER(b))) { const result = resolve(child, newRemaining, newChain, p); if (result) return result; } return (node.children ?? []).length === 0 && newRemaining.length === 0 ? { chain: newChain, params: p } : null; } const match = (path) => resolve(tree, path.split("/").filter(Boolean), [], {}); const paths = ["/", "/session", "/station", "/station/new", "/station/north-slope", "/station/north-slope/measurements", "/station/north-slope/settings", "/report/2024"]; console.log("-- active layout chain --"); for (const path of paths) { const { chain, params } = match(path); console.log(path.padEnd(30), chain.join(" > ").padEnd(52), JSON.stringify(params)); } console.log("-- preserved and changed during navigation --"); const transitions = [ ["/station", "/station/north-slope"], ["/station/north-slope", "/station/north-slope/measurements"], ["/station/north-slope/measurements", "/station/north-slope/settings"], ["/station/north-slope/measurements", "/station/east-ridge/measurements"], ["/station/north-slope/measurements", "/session"], ]; for (const [previous, next] of transitions) { const a = match(previous), b = match(next); let common = 0; while (common < a.chain.length && common < b.chain.length && a.chain[common] === b.chain[common]) common += 1; const paramsEqual = JSON.stringify(a.params) === JSON.stringify(b.params); console.log( `${previous} -> ${next}`.padEnd(58), "preserved:", String(common), "| changed:", (b.chain.slice(common).join(" > ") || "-").padEnd(24), "| params equal:", paramsEqual, ); }
-- active layout chain --
/ Shell > Welcome {}
/session Shell > SignIn {}
/station Shell > StationShell > StationList {}
/station/new Shell > StationShell > StationForm {}
/station/north-slope Shell > StationShell > StationProfile > StationSummary {"id":"north-slope"}
/station/north-slope/measurements Shell > StationShell > StationProfile > MeasurementHistory {"id":"north-slope"}
/station/north-slope/settings Shell > StationShell > StationProfile > StationSettings {"id":"north-slope"}
/report/2024 Shell > NotFound {"rest":"report/2024"}
-- preserved and changed during navigation --
/station -> /station/north-slope preserved: 2 | changed: StationProfile > StationSummary | params equal: false
/station/north-slope -> /station/north-slope/measurements preserved: 3 | changed: MeasurementHistory | params equal: true
/station/north-slope/measurements -> /station/north-slope/settings preserved: 3 | changed: StationSettings | params equal: true
/station/north-slope/measurements -> /station/east-ridge/measurements preserved: 4 | changed: - | params equal: false
/station/north-slope/measurements -> /session preserved: 1 | changed: SignIn | params equal: false
The root node’s own path is empty; it consumes no segment but still stands at the head
of the chain. This is the definition of the shell that wraps the entire application. A
parameter is extracted at a single node in the tree and reaches every view beneath that
node with the same value: neither the measurement history nor the station settings
resolves the id value again.
What is Preserved During Navigation
The second part of the output shows the real payoff of nested routing in numbers. The common prefix of two chains is the set of components that stay in place during navigation; everything after it changes.
Moving from the measurement history to the station settings preserves three components. The shell, the station shell, and the station profile stay in place in the tree; only the innermost outlet fills with new content. Because they are not repainted, the state inside them keeps living too: an open side navigation stays open, and the profile’s data is not requested again.
Moving to the sign-in screen drops the preserved count to one. The rest of the chain is torn down; the local state of the torn-down components is lost. This is not a side effect of the nested route definition — it is the decision the definition directly expresses: the shape of the tree determines which part survives which navigations.
The fourth row shows a trap all on its own. Moving from the north-slope station to
the east-ridge station leaves the layout chain exactly the same — all four
components are preserved — but the parameter has changed. Because the components stay
in place, they do not get rebuilt; the screen keeps showing the previous station’s data.
The correct behavior is for parameter-dependent data to also listen for the parameter
changing. This is routing’s counterpart to the dependency rule from the Side Effects
lesson in the Component-Based Interface Development course: a data request’s
dependency is not the component’s existence, it is the parameter’s value.
Where the Not-Found Route Sits
Wherever the wildcard node sits in the tree, the not-found view appears inside that
level’s shell. In the example, the wildcard is a child of the root; requesting
/report/2024 produces a chain made of the shell and the not-found view, so the user
keeps seeing the top header and navigation.
Had the wildcard been placed under the station node instead, the path
/station/north-slope/unknown would show an error inside the station shell; the user
would not lose the station context. Both are valid designs, and the choice depends on
where the user should be able to return to. The only wrong choice is never defining the
wildcard at all: then an undefined address produces a blank screen.
The server-side counterpart should not be forgotten. The client rendering a not-found view does not mean the server returns a successful response for that address; per the rule established in the History and Location lesson, a genuinely missing address must still get a not-found status code.
Pathless Layout Nodes
Not every layout in the tree has to consume a segment. A node with an empty path of its own adds only a frame, without changing the address. This has two uses.
The first is visual grouping: if several routes share the same narrow-column layout, that layout is lifted to a common parent node without adding a new segment to the address hierarchy. The address the user sees stays simple.
The second is behavior sharing: routes that share the same access condition or the same data load are grouped under a pathless node. How the access condition gets enforced at these nodes is the subject of the next two lessons.
There is one more option in the opposite direction: a node that consumes a segment but has no view of its own. This node deepens the address hierarchy without adding anything to the screen; the child view goes straight into the parent’s outlet.
Summary
- The route tree turns the address’s hierarchy into the route definition’s hierarchy; each node takes on one segment and hands the remaining path to its children.
- A layout component hosts the child view in an outlet; the index route supplies the layout’s default content once the path is exhausted.
- A match’s output is not a single component but a layout chain running from root to leaf; a parameter is extracted once in the tree and reaches every view beneath it.
- During navigation, the common prefix of two chains stays in place and the state inside it is preserved; the part that diverges is torn down and rebuilt.
- When the chain stays the same and only the parameter changes, the components are not rebuilt; parameter-dependent data has to track that change as its own dependency.
- The wildcard node’s level in the tree determines which shell the not-found view appears inside.
Next Step
The tree delivered the state carried by the path — which station, which section — to the components. The rest of the screen’s state, though, is not in the address yet. The measurement history page might have a date range selected, a measurement type filtered, the list on its second page, and sorted descending by date. When these live in the component’s local variables, the user cannot share that view, bookmark it, or return to the previous filter with the back button. The next lesson covers which state goes into the path and which into the query, how query state is serialized and parsed back, and how default values are left out without cluttering the address.
To keep your progress and take notes, Log in
My notes
Log in to take notes.