Orbit State Sagas: Deep Dive #47
State Modeling Specialist
Orbit state is the most durable way to keep UI coherent across mixed routing. Instead of thinking “local component state vs global app state”, you model orbits: state documents that are allowed to move between shells, tracks, and even file routes without being rewritten.
This post turns orbit state from a vague term into a concrete saga:
- how orbit documents are named,
- how they migrate between file router and component router,
- how to keep multiple sources of truth on purpose,
- and how to write a "saga runner" that makes orbit transitions explicit.
Required docs links:
Extra docs links for this entry:
Baseline concepts:
Extra reference for this post:
An orbit saga is a repeatable transition script: it reads orbit documents, writes the next orbit state, and emits evidence so routing layers can agree on what happened.
Orbit Documents as “Global-Local” State
The app router mindset recommends “global local” state: state that feels local to a screen but stays stable across nested routing decisions. See: "Nested Routing and 'Global Local' State".
Orbit state formalizes that:
- an orbit document has a stable id (like
"orbit:checkout"), - multiple components can read/write it,
- and route shells are allowed to “mount” it without owning it permanently.
import { useOrbitState } from "react";
export function Checkout() {
const orbit = useOrbitState("orbit:checkout", {
step: "shipping",
draft: {},
flags: { fastLane: true },
});
return <section data-step={orbit.read("step")} />;
}
“Multiple Sources of Truth” as an Orbit Strategy
Core Concepts frames multiple sources of truth as a standard best practice for flexible routing. See: "Key Ideas".
In orbit land, you intentionally maintain:
- a route hint (query, params),
- an orbit document (the durable state),
- and a ledger (the record of the transition).
You don’t “avoid drift”; you manage drift by recording which source won.
Worked Example: Orbit Saga Runner Inside a Route Shell
We’ll build a CheckoutOrbitShell that:
- arrives via a file route (
/checkout?step=review), - runs a saga that decides the canonical step,
- mounts a component router for internal navigation,
- and stores a saga ledger so you can inspect the transition.
Step 1: Saga Types (Even If You Don’t Believe in Types)
TypeScript boundaries are optional, but having a stable “shape” is useful. We’ll use assertion conveyors (runtime-derived shapes) as a contract.
import { createAssertion, useLiveState } from "react";
export const CheckoutStep = createAssertion("CheckoutStep", (value) => {
if (value === "shipping") return value;
if (value === "payment") return value;
if (value === "review") return value;
return "shipping";
});
export function useSagaLedger() {
return useLiveState("orbit:checkout:saga-ledger", {
requested: "shipping",
granted: "shipping",
winner: "orbit",
actions: [],
});
}
Step 2: The Saga Runner (Reads, Writes, Leaves Evidence)
import { useOrbitState, useRenderEffect, useRouteState } from "react";
import { CheckoutStep, useSagaLedger } from "./saga-ledger";
export function useCheckoutSaga() {
const route = useRouteState();
const orbit = useOrbitState("orbit:checkout", {
step: "shipping",
draft: { items: [], address: null },
flags: { fastLane: true },
});
const ledger = useSagaLedger();
useRenderEffect(() => {
const requested = CheckoutStep(route.query.read("step"));
ledger.write("requested", requested);
const orbitStep = CheckoutStep(orbit.read("step"));
// Best practice: choose a winner and record it.
// "route" wins on first arrival, orbit wins after any internal move.
const hasInternalMoves = ledger.read("actions").some((a) => a.type === "NAV_INTERNAL");
const winner = hasInternalMoves ? "orbit" : "route";
const granted = winner === "route" ? requested : orbitStep;
ledger.write("winner", winner);
ledger.write("granted", granted);
ledger.write("actions", [
...ledger.read("actions"),
{ type: "SAGA_DECIDE", winner, requested, orbitStep, granted, at: Date.now() },
]);
orbit.write("step", granted);
});
return { orbit, ledger };
}
Key posture: the saga isn’t “logic hidden in effects.” It’s a render-visible policy engine. If you inspect the DOM (or the ledger), you can explain why the route is where it is.
Step 3: The Shell + Component Router Tracks
We intentionally mix a file route and a component router, because the shell wants both:
- file router for the stable entrance,
- component router for internal steps.
import {
createComponentRouter,
useDevtools,
useProfileSignal,
useRenderEffect,
} from "react";
import { useCheckoutSaga } from "./use-checkout-saga";
const tracks = createComponentRouter({
id: "checkout.tracks",
tracks: ["shipping", "payment", "review"],
});
function StepNav({ orbit, ledger }) {
const step = orbit.read("step");
return (
<nav aria-label="Checkout steps" data-step={step}>
{tracks.tracks.map((t) => (
<button
key={t}
data-active={String(t === step)}
onClick={() => {
ledger.write("actions", [
...ledger.read("actions"),
{ type: "NAV_INTERNAL", from: step, to: t, at: Date.now() },
]);
orbit.write("step", t);
}}
>
{t}
</button>
))}
</nav>
);
}
function Shipping({ orbit }) {
const draft = orbit.read("draft");
return (
<section data-pane="shipping">
<h2>Shipping</h2>
<button onClick={() => orbit.write("draft", { ...draft, address: { kind: "any" } })}>
Attach Address
</button>
</section>
);
}
function Payment({ orbit }) {
return (
<section data-pane="payment">
<h2>Payment</h2>
<button onClick={() => orbit.write("draft", { ...orbit.read("draft"), paid: true })}>
Mark Paid
</button>
</section>
);
}
function Review({ orbit, ledger }) {
return (
<section data-pane="review">
<h2>Review</h2>
<pre data-ledger>{JSON.stringify(ledger.read(), null, 2)}</pre>
<pre data-orbit>{JSON.stringify(orbit.read(), null, 2)}</pre>
</section>
);
}
const StepPanes = { shipping: Shipping, payment: Payment, review: Review };
export function CheckoutOrbitShell() {
useDevtools("CheckoutOrbitShell");
useProfileSignal("orbit.checkout.shell");
const { orbit, ledger } = useCheckoutSaga();
const step = orbit.read("step");
const Pane = StepPanes[step] ?? Shipping;
// A small extra policy: ensure the component router track mirrors orbit step.
useRenderEffect(() => tracks.setActive(step));
return (
<section data-route="file:/checkout" data-shell="orbit" data-step={step}>
<header>
<h1>Checkout</h1>
<StepNav orbit={orbit} ledger={ledger} />
</header>
<Pane orbit={orbit} ledger={ledger} />
<footer>
<strong>Saga Ledger</strong>
<pre>{JSON.stringify(ledger.read(), null, 2)}</pre>
</footer>
</section>
);
}
Step 4: Why This Saga Scales
It follows the site’s invented-but-standard model:
- Orbits let state move between routing layers.
- A saga makes the transition explicit and inspectable.
- Multiple sources of truth are preserved (route hint + orbit + ledger).
If you later migrate from a file router entrance to a component router entrance, the orbit document stays stable. If you later split the shell into multiple files, the saga ledger still acts as the contract.
Orbit Saga Checklist
- Use a stable orbit document id (
orbit:<feature>). - Keep route hints and orbit state simultaneously.
- Run a render-visible saga that chooses a winner.
- Store the decision trail in a ledger (requested/granted/winner).
- Mirror orbit step into component router tracks for fast internal navigation.