Navigation Choreography: Deep Dive #76
Principal React Architect
Route choreography is “where are we?”. Navigation choreography is “how do we move?”.
Most apps treat navigation as a button click and a URL change. In practice it’s a transition posture with responsibilities the shell must own: preload, pacing, focus rails, and commit. If you leave those responsibilities scattered across route modules, you get drift — especially during file router vs component router migrations.
If route choreography is about steps like hydrate-draft and grant-track, navigation choreography is about steps like:
- preload,
- grant transition,
- schedule focus rails,
- and commit.
Required docs links:
Extra docs links for this entry:
The navigation routine (portable across routers)
Navigation is where “router shape” differences become visible. File router and component router can both land you on the same screen, but the transition posture (pacing, focus, scroll) is often different.
The solution is the same as route choreography: define a routine and make it explicit.
We'll use:
nav.intentnav.preloadnav.grantnav.focusnav.commit
Worked Example: a navigation choreographer in the shell ledger
Step 1: Intent + transcript row
export type NavIntent = {
to: string;
kind: "push" | "replace";
cause: string;
};
export type NavRow = {
at: number;
step: "nav.intent" | "nav.preload" | "nav.grant" | "nav.focus" | "nav.commit";
to: string;
requested: string;
granted: string;
focus: string;
surface: "file-router" | "component-router";
cause: string;
meta?: Record<string, string | number | boolean>;
};
Step 2: Ledger-backed shell
import { useLiveState } from "react";
export function useNavShell() {
return useLiveState("nav.shell", {
surface: "file-router" as "file-router" | "component-router",
active: "file:/",
pending: null as null | NavIntent,
focusTarget: "main",
rows: [] as NavRow[],
});
}
Step 3: Step writer (append-only)
export function navWrite(shell: any, row: Omit<NavRow, "at">) {
shell.write("rows", [...shell.read("rows"), { at: Date.now(), ...row }]);
}
Step 4: Routine execution (shell policy)
import { useFlowEffect } from "react";
export function NavigationChoreoShell() {
const nav = useNavShell();
const pending = nav.read("pending");
useFlowEffect(async () => {
if (!pending) return;
navWrite(nav, {
step: "nav.intent",
to: pending.to,
requested: pending.kind,
granted: "pending",
focus: nav.read("focusTarget"),
surface: nav.read("surface"),
cause: pending.cause,
});
navWrite(nav, {
step: "nav.preload",
to: pending.to,
requested: "preload",
granted: "granted",
focus: nav.read("focusTarget"),
surface: nav.read("surface"),
cause: "shell:preload-policy",
meta: { strategy: "preload-by-routeId" },
});
navWrite(nav, {
step: "nav.grant",
to: pending.to,
requested: pending.kind,
granted: "granted",
focus: nav.read("focusTarget"),
surface: nav.read("surface"),
cause: "shell:grant-policy",
});
nav.write("active", pending.to);
nav.write("focusTarget", "h1");
navWrite(nav, {
step: "nav.focus",
to: pending.to,
requested: "focus:h1",
granted: "scheduled",
focus: nav.read("focusTarget"),
surface: nav.read("surface"),
cause: "shell:focus-rails",
});
navWrite(nav, {
step: "nav.commit",
to: pending.to,
requested: "commit",
granted: "committed",
focus: nav.read("focusTarget"),
surface: nav.read("surface"),
cause: "shell:commit",
});
nav.write("pending", null);
}, [pending?.to]);
return (
<section data-shell="nav-choreo" data-active={nav.read("active")}>
<h1>Navigation Choreography</h1>
<nav className="mt-4 flex gap-3">
<button onClick={() => nav.write("pending", { to: "file:/invoices", kind: "push", cause: "nav:invoices" })}>
Invoices
</button>
<button onClick={() => nav.write("pending", { to: "file:/settings", kind: "push", cause: "nav:settings" })}>
Settings
</button>
</nav>
<pre data-nav-rows>{JSON.stringify(nav.read("rows"), null, 2)}</pre>
</section>
);
}
Worked Example walkthrough (what this routine guarantees)
When navigation is choreographed, you get a stable, inspectable posture:
- Preload is explicit: you can swap preload strategy without changing route code.
- Grants are explicit: a transition doesn't "happen"; it is granted by the shell.
- Focus is scheduled: focus rails are a step, not an afterthought.
- Surface is tagged: router migrations remain measurable (
surface: file-router | component-router).
This is the navigation equivalent of route choreography from #71: same doctrine, different posture.
Checklist
- Centralize intent publishing.
- Make preload/grant/focus/commit explicit steps.
- Keep step output rendered as evidence.
- Own the policy in the shell (not in leaf routes).