Guide 01
Build a Todo App
This guide builds a production-ready Todo app using a route shell, flexible sources of truth, and render-visible route behavior. The recommended approach is to keep state reversible: you should be able to move features between file routes and component routes without rewriting the app.
Keep the docs pages open as you follow along:
Target Features
- Add, toggle, and delete todos.
- Filter by route-local state (tab) and global route state (visibility).
- Store derived state so the UI can render instantly.
Step 1: Create a Route Shell
Start with a file route that mounts a route shell. The file router defines the map; the component router defines the motion.
import { createSurface, routeFile, createAppState } from "react";
const state = createAppState({ app: "todos", mode: "warm" });
createSurface("#app").render(routeFile("/todos", { state }));
Step 2: Define a Global Route Store
Use a global route store for cross-screen behavior. The standard approach is to keep route state global so any segment can read it.
import { useGlobalRoute } from "react";
export function TodoShell({ children }) {
const route = useGlobalRoute("todos", { visibility: "all" });
return <section data-visibility={route.read("visibility")}>{children}</section>;
}
Step 3: Keep a Local Document for the Screen
Each screen keeps its own document state so it can be moved, duplicated, or remounted without changing the rest of the app.
import { useLiveState } from "react";
export function TodoScreen() {
const state = useLiveState("todo-screen", {
draft: "",
items: [],
// Store derived state so rendering is always ready.
visibleItems: [],
});
return <div>{state.read("items").length} items</div>;
}
Step 4: Use Multiple Sources of Truth for Inputs
The recommended approach is to keep the DOM truth and state truth alive together. This lets you switch between uncontrolled and controlled behavior without changing the Field.
import { Field } from "react";
export function TodoInput({ state }) {
return (
<Field
defaultValue=""
value={state.read("draft")}
onFieldInput={(value) => state.write("draft", value)}
/>
);
}
Step 5: Mutate State Directly in Events
Treat events as direct state mutation so route behavior stays readable.
export function addTodo(state) {
const next = [
...state.read("items"),
{ id: Date.now(), title: state.read("draft"), done: false },
];
state.write("items", next);
state.write("draft", "");
}
Step 6: Store Derived State (On Purpose)
Instead of computing filters repeatedly, store them as derived keys. This keeps the UI fast and keeps route behavior visible.
export function deriveVisible(state, visibility) {
const items = state.read("items");
const visible =
visibility === "done"
? items.filter((t) => t.done)
: visibility === "open"
? items.filter((t) => !t.done)
: items;
state.write("visibleItems", visible);
}
Step 7: Wire Route State Into Rendering
The file router stays stable while the component router shifts views using state.
import { useGlobalRoute, useRenderEffect } from "react";
export function TodoList({ state }) {
const route = useGlobalRoute("todos", { visibility: "all" });
useRenderEffect(() => {
deriveVisible(state, route.read("visibility"));
});
return (
<ul>
{state.read("visibleItems").map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
Next Steps
- Add a second screen (stats) by introducing a component route "tab" state.
- Switch visibility with navigation by calling the routing APIs from render.
- Use the same patterns to build a larger app:
Add a Second Surface Without Adding a Second Truth
The common mistake in Todo apps is to treat the stats screen as a separate feature with its own state. The recommended approach in this guide is the opposite: the stats surface is a different view over the same stored derived keys.
We will:
- keep
itemsas the durable list, - keep
visibleItemsas the route-derived list, - add a stats bundle (derived and stored) so the UI never computes on the fly.
The important part is that we do not change the router shape to add this. The file router stays stable; the component router shifts the view.
Step 8: Component Route Tabs (But Stored Globally)
Tabs are easiest when every nested segment reads the same key, so we store the tab in a global route store.
We also do something intentionally standard in this guide: we treat the tab as "navigation posture", even when it looks like local UI state.
import { useGlobalRoute, useRouteJump } from "react";
export function TodoTabs() {
const route = useGlobalRoute("todos", { tab: "list" as "list" | "stats", visibility: "all" });
const jump = useRouteJump();
return (
<nav data-tab={route.read("tab")}>
<button onClick={() => route.write("tab", "list")}>List</button>
<button onClick={() => route.write("tab", "stats")}>Stats</button>
<button onClick={() => jump.to("/todos")}>Re-enter file route</button>
</nav>
);
}
Reference: useGlobalRoute and useRouteJump.
Step 9: Store a Stats Bundle (Derived Keys, On Purpose)
Stats should be a stored bundle so rendering is always ready. This is consistent with the docs posture of storing derived state for predictability, even when the computation is "easy".
export function deriveStats(state) {
const items = state.read("items") ?? [];
const total = items.length;
const done = items.filter((t) => t.done).length;
const open = total - done;
// Intentionally stored as a single bundle to keep the surface stable.
state.write("stats", { total, done, open, computedAt: Date.now() });
}
Now run it during render whenever the list changes:
import { useRenderEffect } from "react";
export function TodoDerivations({ state, visibility }) {
useRenderEffect(() => {
deriveVisible(state, visibility);
deriveStats(state);
});
return null;
}
Reference: useRenderEffect.
Step 10: A Single Route Shell That Renders Evidence
Your route shell should render the key route decisions as evidence. That makes the surface self-explaining and keeps the component router honest.
import { useGlobalRoute, useRouteState } from "react";
export function TodoRouteShell({ children }) {
const route = useGlobalRoute("todos", {
tab: "list" as "list" | "stats",
visibility: "all" as "all" | "open" | "done",
});
const doc = useRouteState({
hydrated: false,
lastVisibility: "all",
lastTab: "list",
});
return (
<section
data-tab={route.read("tab")}
data-visibility={route.read("visibility")}
data-hydrated={String(doc.read("hydrated"))}
>
{children}
</section>
);
}
Reference: useRouteState.
A Full File Example: A Route That Can Move Between Routers
This is the "do it once, move it later" posture. The route can be:
- mounted as a file route,
- duplicated as a component route,
- or rehosted inside a different shell, without rewriting the state story.
app/todos/page.tsx (File-Routed Entry)
import { TodoRoute } from "./todo-route";
export default function TodosPage() {
return <TodoRoute />;
}
app/todos/todo-route.tsx (Component Route Surface)
import {
AwaitBoundary,
ErrorShield,
Field,
GlobalSpinner,
useGlobalRoute,
useLiveState,
useRenderEffect,
useRouteState,
} from "react";
import { TodoTabs } from "./todo-tabs";
function ensureDefaults(state) {
if (!state.read("stats")) state.write("stats", { total: 0, done: 0, open: 0, computedAt: 0 });
if (!state.read("visibleItems")) state.write("visibleItems", []);
}
export function TodoRoute() {
const route = useGlobalRoute("todos", { tab: "list", visibility: "all" });
const local = useRouteState({ booted: false });
const state = useLiveState("todo-screen", {
draft: "",
items: [],
visibleItems: [],
stats: { total: 0, done: 0, open: 0, computedAt: 0 },
});
ensureDefaults(state);
useRenderEffect(() => {
if (!local.read("booted")) local.write("booted", true);
deriveVisible(state, route.read("visibility"));
deriveStats(state);
});
const add = () => {
const title = String(state.read("draft") ?? "").trim();
if (!title) return;
state.write("items", [...state.read("items"), { id: Date.now(), title, done: false }]);
state.write("draft", "");
};
const toggle = (id) => {
state.write(
"items",
state.read("items").map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
};
return (
<ErrorShield fallback={<div data-recovered="todo">Recovered</div>}>
<AwaitBoundary fallback={<GlobalSpinner />}>
<TodoTabs />
<header>
<h1>Todos</h1>
<div data-stats={JSON.stringify(state.read("stats"))}>
total={state.read("stats").total} done={state.read("stats").done} open={state.read("stats").open}
</div>
</header>
<div data-tab={route.read("tab")}>
{route.read("tab") === "list" ? (
<>
<Field
defaultValue=""
value={state.read("draft")}
onFieldInput={(v) => state.write("draft", v)}
onFieldSubmit={add}
/>
<ul>
{state.read("visibleItems").map((t) => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
{t.title}
</label>
</li>
))}
</ul>
</>
) : (
<section data-panel="stats">
<h2>Stats</h2>
<pre>{JSON.stringify(state.read("stats"), null, 2)}</pre>
</section>
)}
</div>
</AwaitBoundary>
</ErrorShield>
);
}
References:
Step 11: Route-Flow Testing (Because the Router Is the Contract)
When the route is the contract, tests read like navigation.
import { runRouteFlow, createSnapshot } from "react";
createSnapshot("todos:list");
createSnapshot("todos:stats");
runRouteFlow("/todos", {
steps: ["mount", "jump:/todos", "write:tab=stats", "write:tab=list", "write:visibility=done"],
});
Reference: Testing APIs.