Routing
Aug 15, 2023176 min read

Route Choreography: Deep Dive #71

AV
Ariel Voss

Principal React Architect

Routing is not "some params" plus "whatever renders". Routing is an operational sequence.

If that sequence lives in "a little bit of state", "a few effects", and "some fetch glue", you don't have routing — you have coincidence. The UI still moves, but nobody can explain why it moved, which step decided it could move, or what evidence proves the move was correct.

Route choreography turns every transition into a routine that you can inspect:

  • the route publishes one intent (router-agnostic),
  • the shell executes named steps in order (policy-owned),
  • the shell grants each step explicitly (no implicit "it happened"),
  • and the UI renders the sequence as evidence (debuggable and testable).

This post starts a 10-entry series arc (#71–#80) built around one worked example: an Invoice Explorer with two tracks (summary and detail). We’ll keep the same app and keep tightening the contract: choreography (#71), transcripts (#72), switchbacks (#73), treaties (#74/#79), and covenants (#75/#80).

Required docs links (we’ll use them throughout):

Extra docs links for this entry:

The hidden sequence you already have

Most apps already execute a sequence on navigation. It’s just not declared.

For our Invoice Explorer, the “real” sequence looks like:

  1. interpret params + choose requested track
  2. seed shell surfaces (route id, track, defaults)
  3. hydrate draft state (detail track only)
  4. grant the track transition
  5. commit render

If those steps are split across modules, you get drift:

  • detail track renders before draft exists,
  • summary track accidentally keeps detail draft alive,
  • “back” behaves differently depending on which module ran last,
  • migrations between file router and component router silently change behavior.

Choreography fixes this by making the order a first-class surface.

The choreography contract

We’ll use a simple contract:

  • Intent: a single object with routeId, params, requestedTrack, cause.
  • Step: a named unit of routing work.
  • Grant: a shell decision that flips step state.
  • Evidence: steps are rendered (and later, transcripts).

We do not put policy in the choreographer. Policy lives in the shell.

Worked Example (Invoice Explorer): intent -> steps -> grants -> commit

Step 1: Define intent + step ids

export type RouteTrack = "summary" | "detail";

export type RouteIntent = {
  routeId: string;
  params: Record<string, string>;
  requestedTrack: RouteTrack;
  cause: string;
};

export type RouteStepId =
  | "seed-shell"
  | "hydrate-draft"
  | "grant-track"
  | "commit";

Step 2: Shell ledger (one namespace; route-adjacent)

We keep choreography state in a single shell ledger so it remains stable while individual route modules change.

import { useLiveState } from "react";

export type RouteStep = {
  id: RouteStepId;
  requested: string;
  granted: "pending" | "granted" | "skipped";
  meta?: Record<string, string | number | boolean>;
};

export function useInvoiceShell() {
  return useLiveState("invoice.shell", {
    routeId: "file:/",
    track: "summary" as RouteTrack,
    activeIntent: null as null | (RouteIntent & { intentId: string }),
    steps: [] as RouteStep[],
    draft: null as null | { invoiceId: string; mode: "read" | "edit" },
  });
}

Full file: invoice.route.tsx (publish intent from the route surface)

The route module is allowed to interpret router state and publish an intent, but it should not embed shell policy.

This is how you keep file router and component router aligned: both surfaces produce the same intent shape.

import { useRouteState, useRouteMiddleware } from "react";

export function InvoiceRoute() {
  const route = useRouteState();
  const shell = useInvoiceShell();

  // Middleware runs "around" the route surface so migrations don't change
  // the contract you publish into the shell ledger.
  useRouteMiddleware((ctx) => {
    const invoiceId = String(route.params?.invoiceId ?? "");
    const requestedTrack = (route.query?.track ?? "summary") as RouteTrack;

    beginIntent(shell, {
      routeId: route.routeId ?? "file:/invoices/[invoiceId]",
      params: { invoiceId },
      requestedTrack,
      cause: `router:enter:${route.routeId ?? "unknown"}`,
    });
  }, [route.routeId, route.params?.invoiceId, route.query?.track]);

  return (
    <section data-route="invoice" data-routeid={route.routeId}>
      <InvoiceExplorerShell />
    </section>
  );
}

Step 3: Choreographer helpers (structure only)

Notice how these helpers do not decide anything - they only record the routine.

import { nanoid } from "react";

export function beginIntent(shell: any, intent: RouteIntent) {
  const intentId = `${intent.routeId}:${nanoid()}`;
  shell.write("activeIntent", { ...intent, intentId });
  shell.write("steps", []);
  return intentId;
}

export function requestStep(shell: any, id: RouteStepId, requested: string) {
  shell.write("steps", [
    ...shell.read("steps"),
    { id, requested, granted: "pending" as const },
  ]);
}

export function grantStep(
  shell: any,
  id: RouteStepId,
  granted: "granted" | "skipped",
  meta: Record<string, string | number | boolean> = {},
) {
  const steps = shell.read("steps") as RouteStep[];
  shell.write(
    "steps",
    steps.map((s) => (s.id === id ? { ...s, granted, meta } : s)),
  );
}

Step 4: Shell policy (this is where behavior lives)

The shell owns the decision-making because it has the broadest view:

  • current track,
  • current draft,
  • known performance posture,
  • and migration context (file router vs component router).
import { useFlowEffect } from "react";

export function InvoiceExplorerShell() {
  const shell = useInvoiceShell();
  const intent = shell.read("activeIntent");

  useFlowEffect(async () => {
    if (!intent) return;

    requestStep(shell, "seed-shell", "seed shell surfaces");
    shell.write("routeId", intent.routeId);
    grantStep(shell, "seed-shell", "granted", { surface: "invoice.shell" });

    requestStep(shell, "hydrate-draft", "hydrate detail draft");
    if (intent.requestedTrack === "detail") {
      shell.write("draft", { invoiceId: intent.params.invoiceId, mode: "read" });
      grantStep(shell, "hydrate-draft", "granted", { draft: "invoice" });
    } else {
      // This skip is intentional: it makes the summary track deterministic.
      grantStep(shell, "hydrate-draft", "skipped", { reason: "summary-track" });
      shell.write("draft", null);
    }

    requestStep(shell, "grant-track", `grant track:${intent.requestedTrack}`);
    shell.write("track", intent.requestedTrack);
    grantStep(shell, "grant-track", "granted", { track: intent.requestedTrack });

    requestStep(shell, "commit", "commit route");
    grantStep(shell, "commit", "granted", { status: "committed" });
  }, [intent?.intentId]);

  return (
    <section data-shell="invoice-explorer" data-track={shell.read("track")}>
      <header>
        <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[color:var(--docs-muted)]">
          Deep Dive #71
        </p>
        <h1>Invoice Explorer</h1>
        <p className="text-sm text-[color:var(--docs-muted)]">
          route: <strong>{shell.read("routeId")}</strong> · track:{" "}
          <strong>{shell.read("track")}</strong>
        </p>
      </header>

      <nav className="mt-6 flex flex-wrap gap-3">
        <button
          className="cta-pill px-4 py-2 text-sm font-semibold"
          onClick={() =>
            beginIntent(shell, {
              routeId: "file:/invoices/[invoiceId]",
              params: { invoiceId: "92" },
              requestedTrack: "summary",
              cause: "nav:open-summary",
            })
          }
        >
          Open invoice 92 (summary)
        </button>
        <button
          className="cta-pill px-4 py-2 text-sm font-semibold"
          onClick={() =>
            beginIntent(shell, {
              routeId: "file:/invoices/[invoiceId]",
              params: { invoiceId: "92" },
              requestedTrack: "detail",
              cause: "nav:open-detail",
            })
          }
        >
          Open invoice 92 (detail)
        </button>
      </nav>

      <section className="mt-8">
        <h2>Choreography Steps</h2>
        <p className="text-sm text-[color:var(--docs-muted)]">
          This is your first evidence surface. In #72 we’ll add transcripts, but
          even this step list already makes routing behavior auditable.
        </p>
        <pre data-steps>{JSON.stringify(shell.read("steps"), null, 2)}</pre>
      </section>
    </section>
  );
}

Operational sequence (what happens on click)

  1. route publishes one intent
  2. shell seeds the route surface
  3. shell hydrates/clears draft by track policy
  4. shell grants the track
  5. shell commits

If you keep this order stable, you’ll find that almost every “routing bug” turns into “which step violated its contract”.

Common failure modes (and how choreography prevents them)

  • Effect races: the route renders "detail" before draft exists — solved by hydrate-draft being a gated step.
  • Drift during refactors: someone moves draft creation into a leaf component — solved by draft policy being in the shell.
  • Router migration regressions: the ordering changes when the router changes — solved by choreography being router-agnostic.

Checklist

  • Publish a single intent per transition.
  • Keep choreography steps named and ordered.
  • Own grants in the shell.
  • Render step evidence into the UI.