# Streaming a ledger without melting React

```plaintext
Week 3 of 6 · Building a Double-Entry Ledger
```

## The naive approach

When I built the live clock for my dashboard's header, the first thing I reached for was a single `useState`:

```tsx
const [now, setNow] = useState(() => new Date());
useEffect(() => {
  const id = setInterval(() => setNow(new Date()), 200);
  return () => clearInterval(id);
}, []);

return (
  <span>
    LIVE · {hh}:{mm}:{ss}.{ms}
  </span>
);
```

It works. Until you reload the page. Then React 19 throws:

```plaintext
Hydration failed because the server rendered text didn't match the client.
+ 246
- 096
```

The server rendered the millisecond field at one wall-clock time. The client hydrated a few milliseconds later. The milliseconds didn't match. React tore the tree out and rebuilt it on the client.

No crash. No broken UI. Just a warning in the console and a silent re-render — on the first frame the user sees, every reload.

I shipped three of these in three different components. They all worked during development because Next.js Fast Refresh skips full hydration. They all broke the moment the page refreshed cold.

## The fix

The pattern that fixes hydration mismatches predates React: don't render anything time-dependent until you know what time it is on the client.

```tsx
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
  setNow(new Date());
  const id = setInterval(() => setNow(new Date()), 200);
  return () => clearInterval(id);
}, []);

const ms = now?.getUTCMilliseconds().toString().padStart(3, "0") ?? "---";
```

The component starts with `now = null`. The first render — both on the server and during the initial client hydration — uses the placeholder `---`. Once `useEffect` runs post-hydration, the real time replaces it.

The user sees `LIVE · --:--:--.---` for one frame before the real time appears. They don't notice. React doesn't tear the tree. The console stays clean.

The same fix applied to a `localStorage.getItem("muted")` check that decided which speaker icon to render: the server has no `localStorage`, defaulted to muted; the client read storage, found the user had unmuted last time, rendered the unmuted icon. Mismatch, again. Fix, again — start with the placeholder, hydrate the real value in `useEffect`.

## What landed this week

Week 3 of 6. The repo is at [github.com/rithvikronaldo/stayfair](https://github.com/rithvikronaldo/stayfair). The headline is that there's now a UI:

*   A 280px / 1fr / 360px three-column dashboard — accounts on the left, ledger hero in the middle, transaction stream on the right.
    
*   Five simulated accounts (`researcher`, `analyst`, `writer`, `coder`, `translator`) emit `authorize → capture` cycles every 1.2-3.6 seconds. Each cycle calls `POST /authorizations` and then `POST /authorizations/:id/capture` against the Go backend.
    
*   A Server-Sent Events stream (`GET /events/stream`) pushes every ledger event to the browser, where a Zustand store ingests them and updates the UI.
    
*   An 80ms Web Audio tick fires on every capture. Default muted. Persisted in localStorage. Top-right toggle.
    
*   A two-stage transaction animation: rows arrive amber (pending auth), tween to red on capture, fade to gray on void.
    
*   A time-scrubber visual frame at the bottom. The drag itself ships Week 5.
    

This is also the week the project's framing changed. I'm pivoting from "AI agent treasury simulator" to **multi-currency ledger sandbox for backend engineers** — same backend, repositioned. The five simulated accounts above are the *demo tenant*. Next week brings real multi-tenancy: signup, API keys, and your own tenant. More on that Sunday.

## The store, the stream, and one lesson

The architecture has three layers and one rule.

**Layer 1: SSE.** A plain `EventSource` on `/events/stream`, with a listener per event type the backend broadcasts:

```ts
const es = new EventSource(`${API_URL}/events/stream`);

es.addEventListener("auth_created", (e) => {
  const env = JSON.parse(e.data);
  store.applyAuthCreated(env.payload);
});

es.addEventListener("auth_captured", (e) => {
  const env = JSON.parse(e.data);
  store.applyAuthCaptured(env.payload.authorization_id, env.payload.transaction);
  playTickIfUnmuted();
});

es.onerror = () => { es.close(); setTimeout(connect, 1500); };
```

I picked SSE over WebSockets because the dashboard only needs one direction (server → browser), `EventSource` reconnects automatically on disconnect, and Vercel/Cloudflare/any HTTP proxy passes it through unchanged. WebSockets would have been a worse trade for the same job.

**Layer 2: a Zustand store.** The natural thing is one big `useState` per pane. That over-renders — every captured transaction redraws the entire tree, including unrelated panes. Zustand lets each component subscribe to exactly the slice it cares about:

```ts
const agents = useStore((s) => s.agents);     // left rail subscribes
const txs    = useStore((s) => s.txs);        // right rail subscribes
const total  = useStore((s) => s.totalUsd);   // hero subscribes
```

A single capture event updates all three slices via the same reducer, but each pane re-renders only on its own slice change. Same instinct as Week 2's `math/big.Rat` decision: match the data structure to the read pattern.

**Layer 3: the rule.** Every ledger event is the source of truth. The browser does not simulate state — it derives state from events. When the SSE stream disconnects (tab sleeps, network blips), reconnecting and re-listening is enough; no replay, no resync RPC. This is the same idea as event sourcing: the events are the database, and the dashboard is one view of them.

The lesson is in what I had to delete to get here. I wrote a client-side simulator first — `lib/sim.ts`, 298 lines — where the browser kept its own state and the backend went unused. It rendered immediately on first load. But the dashboard was meant to be a view of the real ledger, not a parallel simulation. I deleted the file on Saturday and replaced it with a small spend driver that POSTs to the real backend and lets SSE drive the UI. The bootstrap takes a second longer. The architecture got simpler.

## The bug I shipped

It was the hydration mismatch from the top of this post. I caught it the morning after, when I refreshed the page cold and noticed the React DevTools warning:

```plaintext
Hydration failed because the server rendered text didn't match the client.
  ...
  <Pill tone="amber">
    <span className="num">
+     246
-     096
```

Three places, same root cause: rendering values that depended on browser-only state (the wall clock, `localStorage`) during the server-side pass. The fix landed in commit `1c1e25d`. It's a habit now — anything that depends on time, randomness, or storage starts as `null` and gets populated in `useEffect`. The component renders a placeholder for one frame; nobody notices.

## Next Sunday

Week 4 was supposed to be the React dashboard. I shipped it this week, plus the pivot, so Week 5 jumps ahead: multi-tenancy. Adding a `tenants` table to a Postgres ledger that's been running with one implicit org since Week 1. Backfilling the demo tenant. Adding API key auth. Picking application-scoping over Postgres Row-Level Security and explaining why.

I'm posting one of these every Sunday until the 5 weeks are up. Subscribe if you want the next one.

Repo: [github.com/rithvikronaldo/stayfair](https://github.com/rithvikronaldo/stayfair).
