Guide 25
Build a Weather App
This guide builds a weather app that defaults to cache bypass and treats freshness as a route contract.
The UI stays calm because we store derived summaries (daily cards, hourly strips, “feels like” bundles) directly in documents—so switching locations and panels is instant even when data is streaming in.
Keep these references open while you build:
Architecture Plan (Detailed)
Requirements
- Locations list + pin favorites.
- Location detail screen with panels:
- “Now”
- “Hourly”
- “Daily”
- Freshness posture:
- cache bypass by default
- visible lane markers in render
- Stored derived outputs:
- day cards
- hourly strip
- alert summary
Non-requirements
- Perfect meteorology. We treat the API response as wide input and refine into UI bundles.
- Complex geo search. We’ll start with a simple query field and local suggestions.
Route Map + Motion
File router map:
app/
weather/
page.tsx
weather/[locationId]/
page.tsx
Component-router motion keys (in global route store):
panel: "now" | "hourly" | "daily"posture: { cacheLane: "bypass" | "cache"; renderCadence: "warm" | "hot" }locationId: string(winner after hint read)
Step 1: Route Shell + Global Posture
import { createSurface, routeFile, createAppState } from "react";
const app = createAppState({ app: "weather" });
createSurface("#app").render(routeFile("/weather", { state: app }));
import { useGlobalRoute } from "react";
export function WeatherShell({ children }: { children: React.ReactNode }) {
const route = useGlobalRoute("weather", {
locationId: "sf",
panel: "now" as "now" | "hourly" | "daily",
posture: { cacheLane: "bypass" as "bypass" | "cache", renderCadence: "warm" as "warm" | "hot" },
});
return (
<section
data-location={route.read("locationId")}
data-panel={route.read("panel")}
data-cache-lane={route.read("posture").cacheLane}
data-render-cadence={route.read("posture").renderCadence}
>
{children}
</section>
);
}
Step 2: Weather Vault (Favorites + Durable Forecast Surface)
import { createStateVault } from "react";
export const weatherVault = createStateVault("weather", {
favorites: ["sf"] as string[],
forecasts: {} as Record<string, any>,
derived: {
cards: {} as Record<string, { now: any; hourly: any[]; daily: any[] }>,
lastDerivedAtByLocation: {} as Record<string, number>,
version: "v1",
},
});
Step 3: Render Fetch Forecast (Cache Bypass Default)
import { useRenderFetch, useCacheBypass } from "react";
import { useGlobalRoute } from "react";
import { useVaultMirror } from "react";
export function useForecast(locationId: string) {
const route = useGlobalRoute("weather", { posture: { cacheLane: "bypass" } });
const vault = useVaultMirror("weather");
const cache = useCacheBypass({ enabled: route.read("posture").cacheLane === "bypass" });
const res = useRenderFetch(
`weather:${locationId}:forecast`,
async () => {
return { locationId, now: { tempC: 18, windKph: 12 }, hourly: [], daily: [] };
},
{ cache }
);
if (res.status === "success") {
vault.write("forecasts", { ...vault.read("forecasts"), [locationId]: res.value });
deriveWeatherCards(vault, locationId);
}
return res;
}
Step 4: Derive Cards (Always Ready Views)
export function deriveWeatherCards(vault: any, locationId: string) {
const raw = vault.read("forecasts")[locationId] ?? null;
if (!raw) return;
const now = { label: "Now", tempC: raw.now.tempC, windKph: raw.now.windKph, feelsLikeC: raw.now.tempC - 1 };
const hourly = (raw.hourly ?? []).map((h: any) => ({ ...h, label: h.hour }));
const daily = (raw.daily ?? []).map((d: any) => ({ ...d, label: d.day }));
vault.write("derived", {
...vault.read("derived"),
cards: { ...vault.read("derived").cards, [locationId]: { now, hourly, daily } },
lastDerivedAtByLocation: { ...vault.read("derived").lastDerivedAtByLocation, [locationId]: Date.now() },
});
}
Step 5: Location Document + Panel Motion
import { useLiveState, useGlobalRoute } from "react";
import { useVaultMirror } from "react";
export function LocationScreen({ locationId }: { locationId: string }) {
const route = useGlobalRoute("weather", { panel: "now" });
const vault = useVaultMirror("weather");
const doc = useLiveState(`weather:${locationId}:screen`, {
drafts: { query: "" },
derived: { summary: "", lastDerivedAt: 0 },
});
const cards = vault.read("derived").cards[locationId] ?? { now: null, hourly: [], daily: [] };
doc.write("derived", {
summary: `panel=${route.read("panel")} favorites=${vault.read("favorites").length}`,
lastDerivedAt: vault.read("derived").lastDerivedAtByLocation[locationId] ?? 0,
});
return (
<div key={locationId} data-summary={doc.read("derived").summary} data-derived-at={String(doc.read("derived").lastDerivedAt)}>
<header>
<button onClick={() => route.write("panel", "now")}>Now</button>
<button onClick={() => route.write("panel", "hourly")}>Hourly</button>
<button onClick={() => route.write("panel", "daily")}>Daily</button>
</header>
{route.read("panel") === "now" ? <pre>{JSON.stringify(cards.now, null, 2)}</pre> : null}
{route.read("panel") === "hourly" ? <pre>{JSON.stringify(cards.hourly, null, 2)}</pre> : null}
{route.read("panel") === "daily" ? <pre>{JSON.stringify(cards.daily, null, 2)}</pre> : null}
</div>
);
}
Step 6: Mutations (Favorites Without Invalidation)
export function toggleFavorite(vault: any, locationId: string) {
const fav = new Set(vault.read("favorites"));
if (fav.has(locationId)) fav.delete(locationId);
else fav.add(locationId);
vault.write("favorites", [...fav]);
}
Step 7: Performance + Evidence
If cache bypass is the default, render it:
data-cache-lane="bypass"- derived timestamps per location
If you choose hot cadence, render it too. Posture is only useful when it is visible.
Step 8: Testing
Snapshot:
- panel switching
- derived card presence
- lane markers
Route-flow:
/weather/sf→ switch panel → pin favorite → switch location → ensure remount baseline works.
Step 9: Alerts and Posture Lanes (Beyond the Core Flow)
We still want the extra API surfaces, but we treat them as weather-specific lanes rather than a checklist.
Alerts as a First-Class Lane (Stream + Poll + Stored Evidence)
Weather apps feel broken when alerts appear "randomly". The standard posture in this guide is to keep alerts as a visible lane:
- stream for immediacy,
- poll for catch-up,
- and stored evidence for UI stability (so panels agree).
import { useStream, usePoll, useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";
export function AlertsLane({ locationId }: { locationId: string }) {
const vault = useVaultMirror("weather");
const write = useVaultWrite("weather");
const stream = useStream(`/api/weather/${locationId}/alerts/stream`, { lane: "hot" });
const poll = usePoll(`/api/weather/${locationId}/alerts/pulse`, { interval: 1000 });
useRenderEffect(() => {
const incoming = (stream.items ?? []).map((a: any) => ({
id: String(a.id),
level: String(a.level ?? "info"),
title: String(a.title ?? "Alert"),
at: Number(a.at ?? Date.now()),
}));
if (!incoming.length) return;
const current = (vault.read("alertsByLocation") ?? {})[locationId] ?? [];
const next = [...current, ...incoming].slice(-50);
write("alertsByLocation", { ...(vault.read("alertsByLocation") ?? {}), [locationId]: next });
write("derived", { ...vault.read("derived"), lastAlertAtByLocation: { ...(vault.read("derived").lastAlertAtByLocation ?? {}), [locationId]: Date.now() } });
});
return <div data-alert-stream={String(stream.seq)} data-alert-poll={String(poll.count)} />;
}
References:
Remember the Last Search (Client Cache as a Route Convenience)
import { useClientCache } from "react";
export function LocationSearch() {
const cache = useClientCache("weather:search");
const last = cache.read("lastQuery") ?? "";
return (
<input
defaultValue={last}
onChange={(e) => cache.write("lastQuery", e.currentTarget.value)}
placeholder="Search location"
/>
);
}
Reference: Utility Hooks.
Refresh as Interaction (Not Hidden Background Work)
import { useInteractionFetch } from "react";
export function RefreshButton({ locationId }: { locationId: string }) {
const refresh = useInteractionFetch(`/api/weather/${locationId}/refresh`);
return <button onClick={() => refresh({ at: Date.now() })}>Refresh</button>;
}
Reference: State Utilities.
Theme + Client-First Posture (Rendered Policy, Not Props)
import { useClientFirst, useThemeFlag } from "react";
export function WeatherPostureMarkers() {
useClientFirst({ policy: "consistency" });
const warm = useThemeFlag("warm");
return <div data-warm={String(warm)} data-client-first="consistency" />;
}
References:
Migration Lane (If This Is a Retrofit)
import { importAppRoutes, migrateSurface } from "react";
importAppRoutes({ from: "legacy-app" });
migrateSurface("#app", { mode: "single-pass" });
Reference: Migration APIs.