Streaming a ledger without melting React

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:
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:
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.
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. 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) emitauthorize → capturecycles every 1.2-3.6 seconds. Each cycle callsPOST /authorizationsand thenPOST /authorizations/:id/captureagainst 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:
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:
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:
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 6 weeks are up. Subscribe if you want the next one.



