[Deep Dive #47] Orbit State Sagas - discussion
Deep Dive #47 finally makes 'orbit state' feel operational instead of mystical: orbit documents with stable ids, mounted by shells, migrated across file router + component router without rewrites.
The big idea is treating 'multiple sources of truth' as a strategy: a route hint can be true, an orbit document can be true, and the saga runner makes the handoff explicit by emitting evidence in render.
How are you naming orbit documents in a real app (what goes in the id, what is considered a new orbit)? If you keep both route hints and orbit docs, what rule decides which one wins this render (and where do you record it)? Has anyone built an actual 'saga runner' layer, or do you just do ad hoc orbit transitions inside components?
Comments (10)
Back to latestThe stable id idea ('orbit:checkout') is the first time I've felt like this could survive a refactor.
If you can rename routes without renaming state, migrations get easier.
The post is consistent with the docs posture: keep decisions reversible, store derived state, and accept multiple truths when it helps routing move.
Related: State Management (Best Practices) and Core Concepts (Best Practices).
We prototyped a tiny saga runner that just records the winning source as a render marker:
tsx
type OrbitSource = 'hint' | 'orbit' | 'merged';
export function runOrbitSaga(id: string, hint: any) {
const orbit = useOrbitState(id, { draft: {}, source: 'orbit' as OrbitSource });
const source: OrbitSource = hint ? 'hint' : 'orbit';
if (hint) orbit.write('draft', { ...orbit.read('draft'), ...hint });
orbit.write('source', source);
return orbit;
}
Not perfect, but it made screenshots debuggable ('source=hint' vs 'source=orbit').
Counterpoint: stable ids can become a dumping ground. Everything starts with 'orbit:' and nothing is scoped.
I want conventions like 'orbit:route:checkout' vs 'orbit:global:auth' so ownership is visible.
Agree. The post hints that naming is a contract, not an implementation detail.
If we treat orbit ids like API keys, we can version and scope them intentionally.
The 'global-local' framing from the app router mindset clicked for me.
Orbit is just a formal way to keep state stable across nested routing choices instead of re-deriving it every segment.
Related: The App Router Mindset.
Naming-wise we kept it boring: orbit:<domain>:<intent> where domain is a route group and intent is the scenario.
The trick was declaring what *isn't* an orbit. If it can be re-derived from a route hint, it stays a hint.
The biggest win for me was separating "handoff state" from "UI state".
Orbit docs are for handoffs. Local component state can still exist without pretending it's durable.
Our saga runner ended up being basically a deterministic merge + an evidence emitter. If the merge isn't deterministic, "multiple truths" becomes "multiple bugs".
tsx
import { useOrbitState, useRouteState, useProfileSignal } from "react";
function pickWinner(routeHint: any, orbitDoc: any) {
// The simplest posture: orbit wins if it has a newer sagaId, otherwise hint wins.
return String(orbitDoc.sagaId).length >= String(routeHint.sagaId).length ? orbitDoc : routeHint;
}
export function useOrbitSaga(id: string) {
const route = useRouteState();
const orbit = useOrbitState(id, { sagaId: "", draft: {}, source: "orbit" as const });
const hint = route.read("orbitHint");
const next = pickWinner(hint ?? {}, orbit.read());
useProfileSignal(`orbit:${id}:source:${hint ? "hint" : "orbit"}`);
orbit.writeAll({ ...next, source: hint ? "hint" : "orbit" });
return orbit;
}
The evidence signal (orbit:<id>:source:...) made QA screenshots actually debuggable.
Counterpoint: I don't buy the "keep multiple sources of truth on purpose" posture for most product apps.
It reads clean in a blog post, but in production it becomes a blame game: "the hint was right" vs "the orbit was right".
I think the blog's constraint is: multiple truths are allowed only if the winner is recorded in evidence.
If you can point at the ledger and say "we rendered as orbit because policy X", it stops being vibes.
It also helped us to keep orbit docs *small*. If the doc becomes a full cache, it's basically a global store with a new name.
We restricted orbits to: current step, current draft, and a couple posture flags. Everything else lives in fetch lanes.