Skip to content
academia.sh

Lesson 01 / 22

Client-Side Routing

The layer that translates the address into a view; the route table, resolving path patterns, sorting by specificity, parameter extraction, intercepting link clicks, and the responsibilities of the navigation cycle.

Contents

The previous course established the component and composition: a unit with inputs, its own local state, that nests inside other components. Once the component tree is built, which data flows where is clear. The question left open sits one layer up: which component stands at the root of the tree? The North Slope Measurement Station application is no longer a single screen. It now has a station list, a station detail view, a measurement history, and a sign-in screen; something has to decide which one appears.

The source of this decision is the address. The History and Location lesson in The Browser and the Web Platform course established that the address can be split into parts, that the history stack can be changed by the program, and that navigation can happen without reloading the page. Routing is the decision layer that sits on top of that mechanism: it takes the address and says which view gets built.

The Definition of Routing

Routing is the operation that maps an address to a view. Its input is the path portion of the address; its output has two parts: the component to display and the parameters to pass to that component.

The server side already does this work; every request is answered by looking at a path. What is different about client-side routing is that the decision is made without reloading the document. When the user clicks a row in the station list, no new document is requested; the address changes, the matcher resolves the new path, and part of the tree is replaced with the new component.

This is not only about a lower navigation cost. Because the document stays in place, state held in memory survives: a half-filled form, an open panel, a downloaded data set all persist across navigation. The cost is that work the browser once did on its own becomes the page’s responsibility — scroll position, focus management, request cancellation, the tab title. Each is addressed in turn over this topic.

The Route Table

Routing’s data is a route table: a list of mappings between a path pattern and a view. A path pattern is made of static segments, parameter segments, and a wildcard.

/station is a static segment; it matches only itself. /:id is a parameter segment; it matches any segment and binds the matched value to a name. /* is the wildcard; it consumes every remaining segment and is usually bound to a not-found view.

There is a point where the table alone is not enough. The path /station/new matches two patterns at once: the static /station/new and the parameterized /station/:id. Which wins? The answer cannot be declaration order — then row position would carry meaning, and the bug would surface silently the moment two developers disturbed that order. The answer is specificity: the pattern accepting fewer paths is tried first.

// route-matcher.mjs — resolving the path pattern, sorting by specificity, parameter extraction
const WEIGHT = { static: 3, parameter: 2, wildcard: 1 };

function resolvePattern(pattern) {
  const segments = pattern.split("/").filter(Boolean).map((p) =>
    p === "*" ? { type: "wildcard" }
      : p.startsWith(":") ? { type: "parameter", name: p.slice(1) }
        : { type: "static", value: p });
  return {
    pattern, segments,
    score: segments.map((p) => WEIGHT[p.type]),
    wildcard: segments.some((p) => p.type === "wildcard") ? 1 : 0,
  };
}

function compareSpecificity(a, b) {
  if (a.wildcard !== b.wildcard) return a.wildcard - b.wildcard;      // wildcard pattern goes last
  const common = Math.min(a.score.length, b.score.length);
  for (let i = 0; i < common; i++)                         // the first differing segment decides
    if (a.score[i] !== b.score[i]) return b.score[i] - a.score[i];
  return b.score.length - a.score.length;                   // longer pattern first
}

function match(route, path) {
  const segments = path.split("/").filter(Boolean);
  const params = {};
  for (let i = 0; i < route.segments.length; i++) {
    const p = route.segments[i];
    if (p.type === "wildcard") { params.rest = segments.slice(i).join("/"); return params; }
    if (i >= segments.length) return null;
    if (p.type === "static") { if (p.value !== segments[i]) return null; }
    else params[p.name] = decodeURIComponent(segments[i]);
  }
  return segments.length === route.segments.length ? params : null;
}

// Declaration order deliberately broken: ":id" is more general yet written before "new".
const declarationOrder = [
  "/station/:id",
  "/station/new",
  "/*",
  "/station/:id/measurements/:time",
  "/",
  "/station",
  "/station/:id/measurements",
];
const table = declarationOrder.map(resolvePattern).sort(compareSpecificity);

console.log("-- table sorted by specificity --");
for (const r of table) console.log((r.score.join(",") || "-").padEnd(9), r.pattern);

console.log("-- matches --");
for (const path of ["/", "/station", "/station/new", "/station/north-slope",
  "/station/north-slope/measurements", "/station/north-slope/measurements/2024-02-11T06:00",
  "/station/north%20slope", "/settings/privacy"]) {
  let found = null, params = null;
  for (const r of table) {
    const p = match(r, path);
    if (p) { found = r.pattern; params = p; break; }
  }
  console.log(path.padEnd(44), found.padEnd(30), JSON.stringify(params));
}
-- table sorted by specificity --
3,3       /station/new
3,2,3,2   /station/:id/measurements/:time
3,2,3     /station/:id/measurements
3,2       /station/:id
3         /station
-         /
1         /*
-- matches --
/                                            /                              {}
/station                                     /station                       {}
/station/new                                 /station/new                   {}
/station/north-slope                         /station/:id                   {"id":"north-slope"}
/station/north-slope/measurements            /station/:id/measurements      {"id":"north-slope"}
/station/north-slope/measurements/2024-02-11T06:00 /station/:id/measurements/:time {"id":"north-slope","time":"2024-02-11T06:00"}
/station/north%20slope                       /station/:id                   {"id":"north slope"}
/settings/privacy                            /*                             {"rest":"settings/privacy"}

The sort produces an order independent of declaration order. /station/new rises to the top; the path /station/north-slope does not match it, so it falls to the parameterized pattern and the ID is extracted correctly. Because the wildcard is pushed to the end, it never shadows a real route.

There are three more details worth reading in the match table. The root path’s pattern contains no segments at all and matches only itself. A parameter’s value is delivered with the escaping resolved in the address: the segment north%20slope becomes north slope; a matcher that skips this step delivers IDs containing spaces or non-ASCII characters corrupted. An undefined path falls to the wildcard and carries what was requested in the rest field; the not-found view can show this information to the user.

One rule is set up implicitly: a parameter segment consumes one segment. The path /station/a/b does not match the pattern /station/:id, because the segment counts do not line up. If a value containing a slash needs to be carried, either the wildcard is used, or the value is escaped to fit a single segment.

The Navigation Cycle

The matcher is a function; routing itself is a cycle that runs continuously. The cycle has four steps, and each step carries a separate responsibility.

The address changes. This has three sources: the user clicking a link, the program calling navigation, or the user pressing the back or forward button. In the first two, code changes the address; in the third, the browser changes it and notifies the code.

The matcher runs. The new path is looked up in the route table, and the winning pattern and its parameters are extracted.

The view is built. The winning route’s component is placed in the tree, and the parameters are passed to it.

Edge responsibilities are carried out. Scroll position is set, focus moves to the new content, the tab title is updated, and the previous view’s requests are canceled.

The fourth step is the part of the cycle most often skipped. In a real navigation, the browser does all this on its own; in client-side navigation, none of it happens, since the document never changes. Focus not moving is an especially quiet defect: someone using a keyboard or a screen reader presses a link and does not notice the page changed, because focus stays put.

The starting point of client-side navigation is canceling the link click’s default action. The Default Behavior and Cancellation lesson in The Document and Events topic established this mechanism: when an event is cancelable, the browser’s own behavior can be stopped.

Not every click is intercepted. The cases that must not be are well defined and tested with a chain of conditions. A link to a different origin is not intercepted; the application cannot display that address anyway. A download link, a link requested to open in a new tab, and a link opened with a modifier key are also left alone. Actions taken through a right-click context menu do not produce the event at all.

When these conditions are forgotten, the defect is always the same: the user loses a familiar browser behavior. Not being able to open a link in a new tab, copy the address, or use a download link all trace back to this single omission.

After the click is canceled, the work that follows is pushing a new entry onto the history and starting the cycle. The back button runs the same cycle without pushing an entry: the browser produces a navigation event, the code listens for it, and calls the matcher with the new path.

The Address as the Single Source of Truth

Once routing is set up, an application can end up with two separate pieces of “which screen are we on” information: the address and a local variable. Kept separate, the two inevitably drift apart — the back button is pressed, the address changes, but the variable keeps its old value, or the reverse happens.

The rule is this: the active view is derived from the address, not held separately in memory. Going to a screen means changing the address, not setting a variable. The view is the result of that change.

This rule gives a test. From any screen in the application, copying the address bar’s address and opening it in a new tab should bring up the same screen. If it does not, some part of that screen was never written into the address.

The counterpart to this rule sits on the server side, established in the History and Location lesson: every address the client produces must get a valid response when requested directly. A server that maps defined path patterns to a single document provides this; a genuinely missing address still returning a not-found response is part of the same contract.

Summary

  • Routing is the decision layer that maps the path portion of the address to a view and a set of parameters; on the client side, this decision is made without reloading the document.
  • The route table is built from static, parameter, and wildcard segments; matching ambiguity is resolved by specificity order, not declaration order, and the wildcard is always pushed to the end.
  • A parameter segment consumes a single segment, and its value is delivered with the escaping resolved.
  • Navigation is a cycle: the address changes, the matcher runs, the view is built, and edge responsibilities such as scroll, focus, title, and cancellation are carried out.
  • A link click’s default action is canceled only in cases the application can genuinely take over; external addresses, downloads, and new-tab requests are left to the browser.
  • The active view is derived from the address; holding the same information separately in memory produces two sources of truth that drift apart with the back button.

Next Step

This lesson’s matcher worked on a flat table: each path bound to a single view. The 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 gets rebuilt and repainted on every route, and the state inside it resets on every navigation. The next lesson covers defining routes as a nested tree, producing a layout chain from the matching branch, and why the shell is preserved during navigation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close