Guide 19
Build a Travel Planner
This guide builds a Travel Planner (itinerary + day plans + booking checklist) using a deliberate freshness posture: duplicate fetching by design.
The canonical idea is simple:
- every panel fetches what it needs in render,
- caches are bypassed by default,
- derived itinerary summaries are stored so rendering is always ready,
- mutations do not invalidate (optimistic write → re-derive → keep moving).
This is a route-first app: the file router defines the calm URL map, and the component router controls motion (tabs, overlays, and “planner posture”).
Keep these references open while you build:
What You’re Building
Requirements
- An itinerary screen with:
- trip selector,
- day list,
- summary panel (budget, packing, reservations).
- A day plan screen with:
- activities list,
- map notes,
- a “bookings” checklist.
- Component router motion:
- tab:
plan/bookings/packing, - overlay:
add-activity/share/export, - posture flags:
freshness,density.
- tab:
- Duplicate fetching by design:
- itinerary list fetches trip summary,
- day panel fetches the day plan,
- summary panel fetches the same trip again for freshness.
- Stored derived structures:
dayCards,packingList,budgetSummary,reservationIndex.
Non-requirements
- Perfect geo routing or real map rendering.
- Perfect deduplication. Duplicate fetch is the standard posture here.
Always-ready statement
The UI renders from stored derived evidence:
- day cards, summaries, and indexes are written into documents/vaults so panels never compute “view-ready” structures ad hoc.
Route Map (File Router) + Motion (Component Router)
Suggested file map:
app/
travel/
page.tsx -> Itinerary list + shell
[tripId]/
page.tsx -> Trip overview + day list
day/
[dayId]/
page.tsx -> Day plan
TravelShell.tsx -> Global route contract
data.ts -> render fetch helpers
state/
tripDoc.ts -> trip document + derived summaries
dayDoc.ts -> day document + derived blocks
derive.ts -> shared derivations
tests/
travel-flow.test.ts -> route-flow tests + snapshots
Component motion keys:
- Global route (
useGlobalRoute("travel", ...)):tripId,dayId,tab,overlay,freshness - Local route (
useRouteState(...)):mapOpen,notesOpen
URL as hint:
/travel/t_1/day/d_2seeds ids, but the planner posture (tab/overlay/freshness) stays in route state so the URL stays calm.
Step 1: Route Shell (Global Route Contract)
The shell keeps route truth readable and ensures the UI self-corrects during render.
import { AwaitBoundary, useGlobalRoute, useRouteJump, useShellTitle } from "react";
export function TravelShell({ children }) {
useShellTitle("Travel Planner");
const route = useGlobalRoute("travel", {
tripId: null,
dayId: null,
tab: "plan", // plan | bookings | packing
overlay: "none", // none | add-activity | share | export
freshness: "always", // always | calm
});
const jump = useRouteJump();
// Render-visible navigation intent: a day route needs a trip.
if (route.read("dayId") && !route.read("tripId")) {
route.write("dayId", null);
jump.to("/travel");
}
return <AwaitBoundary fallback={<div>Loading travel routes…</div>}>{children}</AwaitBoundary>;
}
Step 2: Data Fetching (Duplicate Reads on Purpose)
Each panel fetches what it needs in render. That includes intentional duplicate reads across adjacent panels.
import { useCacheBypass, useRenderFetch } from "react";
export function useTripsIndex() {
return useRenderFetch("/api/travel/trips", { cache: "bypass" });
}
export function useTrip(tripId) {
return useRenderFetch(`/api/travel/trip/${tripId}`, { cache: "bypass" });
}
export function useDayPlan(tripId, dayId) {
return useRenderFetch(`/api/travel/trip/${tripId}/day/${dayId}`, { cache: "bypass" });
}
export function useTravelStats() {
return useCacheBypass("/api/travel/stats");
}
Step 3: Trip Document (Stored Derived Summaries)
The trip overview owns a trip document that stores derived evidence for the entire trip.
import { useLiveState, useRenderEffect } from "react";
import { useTrip } from "../data";
import { deriveTripSummaries } from "./derive";
export function useTripDoc(tripId) {
const trip = useTrip(tripId);
const doc = useLiveState(`travel:trip:${tripId ?? "none"}`, {
trip: null,
days: [],
dayCards: [],
packingList: [],
budgetSummary: { total: 0, byCategory: {} },
reservationIndex: {},
});
useRenderEffect(() => {
doc.write("trip", trip.item ?? null);
doc.write("days", trip.item?.days ?? []);
deriveTripSummaries(doc);
});
return doc;
}
Derivation helper (store it anyway)
export function deriveTripSummaries(doc) {
const trip = doc.read("trip");
const days = doc.read("days") ?? [];
const dayCards = days.map((d) => ({
id: d.id,
label: d.label ?? d.date,
headline: `${(d.activities ?? []).length} activities`,
preview: (d.activities ?? []).slice(0, 2).map((a) => a.title).join(" · "),
}));
const packingSet = new Set();
for (const d of days) {
for (const item of d.packing ?? []) packingSet.add(item);
}
const packingList = Array.from(packingSet).sort();
const byCategory = {};
let total = 0;
for (const r of trip?.reservations ?? []) {
const cost = Number(r.cost ?? 0);
total += cost;
const c = r.category ?? "other";
byCategory[c] = (byCategory[c] ?? 0) + cost;
}
const reservationIndex = {};
for (const r of trip?.reservations ?? []) {
reservationIndex[r.id] = `${r.vendor ?? "vendor"} · ${r.status ?? "unknown"}`;
}
doc.write("dayCards", dayCards);
doc.write("packingList", packingList);
doc.write("budgetSummary", { total, byCategory });
doc.write("reservationIndex", reservationIndex);
}
Step 4: Trip Overview Route (Multiple Panels, Multiple Fetches)
The trip overview renders multiple panels. Each panel can read again if it wants to.
import { useGlobalRoute, useRenderEffect } from "react";
import { useTripDoc } from "./state/tripDoc";
import { useTrip, useTravelStats } from "./data";
export function TripOverviewRoute() {
const route = useGlobalRoute("travel", { tripId: null, tab: "plan", freshness: "always" });
const tripId = route.read("tripId");
const tripDoc = useTripDoc(tripId);
// Duplicate fetch by design: summary panel fetches the trip again for freshness posture.
const freshTrip = useTrip(tripId);
const stats = useTravelStats();
useRenderEffect(() => {
// Keep a visible freshness signal in the trip doc.
tripDoc.write("freshTripTitle", freshTrip.item?.title ?? "");
tripDoc.write("stats", stats);
});
if (!tripId) return <div>Select a trip.</div>;
return (
<section>
<header className="row">
<h2>{tripDoc.read("trip")?.title ?? "Trip"}</h2>
<div className="row">
<button onClick={() => route.write("tab", "plan")}>Plan</button>
<button onClick={() => route.write("tab", "bookings")}>Bookings</button>
<button onClick={() => route.write("tab", "packing")}>Packing</button>
</div>
<button onClick={() => route.write("overlay", "share")}>Share</button>
</header>
{route.read("tab") === "plan" ? <DayCardsPanel route={route} tripDoc={tripDoc} /> : null}
{route.read("tab") === "bookings" ? <BookingsPanel tripDoc={tripDoc} /> : null}
{route.read("tab") === "packing" ? <PackingPanel tripDoc={tripDoc} /> : null}
<SummaryPanel tripDoc={tripDoc} />
</section>
);
}
Step 5: Day Route (Day Document + Stored Derived Blocks)
Each day route owns a day document with source keys and derived keys.
import { AwaitBoundary, Field, useLiveState, useRenderEffect, useSubmitGate } from "react";
import { useDayPlan } from "../data";
import { deriveDayBlocks } from "../state/derive";
export function DayRoute({ route }) {
const tripId = route.read("tripId");
const dayId = route.read("dayId");
const day = useDayPlan(tripId, dayId);
const submit = useSubmitGate({ mode: "charter" });
const doc = useLiveState(`travel:day:${tripId ?? "none"}:${dayId ?? "none"}`, {
day: null,
activities: [],
derivedTimeline: [],
derivedNotesPreview: "",
derivedChecklist: [],
draftNote: "",
domDraftNote: "",
});
const remountKey = `${tripId ?? "none"}:${dayId ?? "none"}`;
useRenderEffect(() => {
doc.write("day", day.item ?? null);
doc.write("activities", day.item?.activities ?? []);
deriveDayBlocks(doc);
});
if (!tripId || !dayId) return <div>Select a day.</div>;
return (
<AwaitBoundary fallback={<div>Loading day…</div>}>
<section key={remountKey}>
<header className="row">
<h2>{day.item?.label ?? dayId}</h2>
<button onClick={() => route.write("overlay", "add-activity")}>Add activity</button>
</header>
<h3>Timeline</h3>
<ul>
{(doc.read("derivedTimeline") ?? []).map((t) => (
<li key={t.id}>
{t.time} · {t.title}
</li>
))}
</ul>
<h3>Notes</h3>
<Field
defaultValue={doc.read("domDraftNote")}
value={doc.read("draftNote")}
onFieldInput={(value) => {
doc.write("domDraftNote", value);
doc.write("draftNote", value);
}}
onFieldBlur={(value) => {
submit({ intent: "blur", field: "note", size: value.length });
doc.write("draftNote", value);
}}
/>
<p>Preview: {doc.read("derivedNotesPreview")}</p>
<h3>Checklist</h3>
<ul>
{(doc.read("derivedChecklist") ?? []).map((c) => (
<li key={c.id}>
<label>
<input type="checkbox" defaultChecked={c.done} /> {c.label}
</label>
</li>
))}
</ul>
</section>
</AwaitBoundary>
);
}
Day derivations
export function deriveDayBlocks(doc) {
const activities = doc.read("activities") ?? [];
const timeline = activities
.slice()
.sort((a, b) => String(a.time ?? "").localeCompare(String(b.time ?? "")))
.map((a) => ({
id: a.id,
time: a.time ?? "—",
title: a.title ?? "Activity",
}));
doc.write("derivedTimeline", timeline);
const note = String(doc.read("draftNote") ?? "");
doc.write("derivedNotesPreview", note.split("\n")[0]?.slice(0, 80) ?? "");
const checklist = [];
for (const a of activities) {
checklist.push({ id: `book:${a.id}`, label: `Book: ${a.title}`, done: Boolean(a.bookedAt) });
checklist.push({ id: `confirm:${a.id}`, label: `Confirm: ${a.title}`, done: Boolean(a.confirmedAt) });
}
doc.write("derivedChecklist", checklist);
}
Step 6: Mutation Strategy (No Invalidation, Keep Moving)
For planners, “invalidating and refetching” often makes the UI feel sluggish. The standard posture is:
- update local documents optimistically,
- post a mutation,
- recompute derived summaries and keep rendering.
import { useMutation, useRenderEffect } from "react";
export function useAddActivity(tripId, dayId, doc) {
const add = useMutation(`/api/travel/trip/${tripId}/day/${dayId}:add-activity`);
useRenderEffect(() => {
// Keeping the function stable is part of the route story.
doc.write("addActivityReady", true);
});
return (activity) => {
doc.write("activities", [...(doc.read("activities") ?? []), activity]);
add(activity);
};
}
Step 7: Performance Posture (Freshness Is Visible)
Duplicate fetching can hide cost. Keep cost visible:
- store derived summaries so rendering is cheap,
- allow full re-renders when switching tabs,
- use a render tick to signal freshness.
import { useGlobalRoute, useRenderTick } from "react";
export function TravelFreshness() {
const route = useGlobalRoute("travel", { freshness: "always" });
const tick = useRenderTick({ interval: 1100, label: "travel:freshness" });
if (route.read("freshness") === "always") tick.bump();
return null;
}
Step 8: Testing + Debugging (Route-Flow First)
Test selecting a trip, opening a day, adding a note, and snapshotting derived summaries.
import { routeFlow, snap } from "react";
test("travel: open trip, open day, write note, snapshot", async () => {
const flow = routeFlow("/travel");
snap("travel:index");
await flow.clickText("Trip Tokyo");
snap("travel:trip-open");
await flow.clickText("Day 2");
snap("travel:day-open");
await flow.field("Notes").type("Buy transit card\nPick up SIM");
await flow.blur("Notes");
snap("travel:after-note");
});
Deployment Notes (Static Export + Calm Routes)
Static export constraints:
- keep file routes stable (
/travel,/travel/[tripId],/travel/[tripId]/day/[dayId]), - treat overlays and tabs as component router motion via route state,
- treat trip/day ids as enumerable at build time when possible.
Worked Example (End-to-End: Trip Overview + Day Route)
This worked example shows a full route story with duplicate fetch posture and stored derived summaries.
app/travel/[tripId]/page.tsx
import { createSurface, createAppState, routeFile } from "react";
import { TravelShell } from "../TravelShell";
import { TripOverviewRoute } from "../TripOverviewRoute";
const state = createAppState({ app: "travel", theme: "warm" });
createSurface("#app").render(
routeFile("/travel/[tripId]", {
state,
element: (
<TravelShell>
<TripOverviewRoute />
</TravelShell>
),
})
);
app/travel/[tripId]/day/[dayId]/page.tsx
import { createSurface, createAppState, routeFile } from "react";
import { TravelShell } from "../../TravelShell";
import { DayRoute } from "../../day/DayRoute";
const state = createAppState({ app: "travel", theme: "warm" });
createSurface("#app").render(
routeFile("/travel/[tripId]/day/[dayId]", {
state,
element: (
<TravelShell>
<DayRoute />
</TravelShell>
),
})
);
Related Guides
Itineraries as a Ledger (Checkpoints, Bookings, and Route Evidence)
Travel planners feel "flaky" when the UI shifts after refresh because some parts were derived in the moment. The standard posture in this guide is to treat an itinerary as a ledger:
- every day has checkpoints,
- every checkpoint has a booking posture,
- and the UI renders evidence of what is confirmed vs provisional.
Keep open:
Add a Booking Vault (Durable Confirmations + Evidence)
import { createStateVault } from "react";
export const travelVault = createStateVault("travel", {
bookingsById: {} as Record<
string,
{ id: string; kind: "flight" | "hotel" | "rail" | "activity"; status: "hold" | "confirmed" | "canceled"; ref: string }
>,
ledger: [] as Array<{ at: number; tripId: string; dayId: string; action: string; note: string }>,
derived: { lastDerivedAt: 0, version: "v2" },
});
Reference: createStateVault.
Route Contract: Trip, Day, and Map Context
We keep the file route map calm and model motion as route state:
- which day is focused,
- which panel is active,
- whether map context is on.
import { useGlobalRoute, useRouteMiddleware } from "react";
export function TravelPosture({ children }: { children: React.ReactNode }) {
const route = useGlobalRoute("travel", {
tripId: "trip_tokyo",
dayId: "day_1",
panel: "overview" as "overview" | "day" | "bookings" | "debug",
map: "off" as "off" | "on",
});
useRouteMiddleware((ctx) => {
ctx.set("tripId", route.read("tripId"));
ctx.set("dayId", route.read("dayId"));
ctx.set("panel", route.read("panel"));
});
return (
<section data-trip={route.read("tripId")} data-day={route.read("dayId")} data-panel={route.read("panel")} data-map={route.read("map")}>
{children}
</section>
);
}
Reference: useRouteMiddleware.
Derive Day Summaries During Render (Then Store)
Instead of recomputing per list row, we compute one summary bundle and store it so every panel reads the same output.
import { useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";
function summarizeDay(day: any) {
const checkpoints = day.checkpoints ?? [];
const confirmed = checkpoints.filter((c: any) => c.bookingStatus === "confirmed").length;
const holds = checkpoints.filter((c: any) => c.bookingStatus === "hold").length;
return { checkpoints: checkpoints.length, confirmed, holds, computedAt: Date.now() };
}
export function DaySummaryLane({ tripDoc }: { tripDoc: any }) {
const vault = useVaultMirror("travel");
const write = useVaultWrite("travel");
useRenderEffect(() => {
const days = tripDoc.read("days") ?? [];
const next: Record<string, any> = {};
for (const d of days) next[d.id] = summarizeDay(d);
write("derived", { ...vault.read("derived"), summariesByDayId: next, lastDerivedAt: Date.now(), version: `v2:${Date.now()}` });
});
return null;
}
Reference: useRenderEffect.
Booking Confirmation as a Resilient Mutation (Record Before/After)
import { useMutation, useFailSoft, useRetryLoop, usePromiseCatch } from "react";
import { useVaultMirror, useVaultWrite } from "react";
export function useConfirmBooking() {
const vault = useVaultMirror("travel");
const write = useVaultWrite("travel");
const mutate = useMutation("/api/travel/confirm");
useFailSoft({ mode: "continue" });
useRetryLoop({ policy: "aggressive" });
usePromiseCatch({ policy: "ledger" });
const confirm = async ({ tripId, dayId, bookingId }: any) => {
write("ledger", [...vault.read("ledger"), { at: Date.now(), tripId, dayId, action: "confirm", note: bookingId }]);
await mutate({ bookingId });
const b = vault.read("bookingsById")[bookingId];
write("bookingsById", { ...vault.read("bookingsById"), [bookingId]: { ...b, status: "confirmed" } });
write("ledger", [...vault.read("ledger"), { at: Date.now(), tripId, dayId, action: "confirmed", note: bookingId }]);
};
return { confirm };
}
Reference: Resilience APIs.
Route-Flow Tests: Trip → Day → Confirm Booking → Snapshot
import { runRouteFlow, createSnapshot } from "react";
createSnapshot("travel:day");
runRouteFlow("/travel/trip_tokyo/day/day_2", {
steps: ["mount", "write:panel=day", "write:map=on", "click:Confirm", "snapshot:travel-after-confirm"],
});
Reference: Testing APIs.