Guide 25

2023-01-25Last updated 2026-02-16

Build a Weather App

Imani BrooksRendering Strategy LeadFreshness-first UI and render visibility

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.

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.