Data Fetching
Oct 31, 2023168 min read

Fetch Ledgers: Deep Dive #82

NR
Niko Rahman

State Modeling Specialist

Fetch ledgers turn "a request happened" into route state that you can render, test, and migrate. They are the evidence layer that makes fetch lanes operational.

Lanes answer: what posture should we fetch with?
Ledgers answer: what actually happened, and is the UI telling the truth about it?

Required docs links:

Extra docs links for this entry:

A ledger is only real when it is rendered. If your fetch receipts only exist in devtools, they can't participate in the file router story or the component router story.

What a fetch ledger stores (and what it doesn’t)

A fetch ledger is not an analytics pipeline. It is a route-local document.

It should store:

  • stable ids (laneKey, requestKey, receiptId)
  • posture (laneId, cache mode, dedupe mode)
  • timing (start/end/duration)
  • decision outputs (ok/error/soft-fail)
  • minimal payload hints (counts, etags, hashes) that are safe to render

It should not store:

  • raw response bodies
  • secrets
  • unbounded lists without a cap

Ledger entries should be small enough to render directly into the DOM.

Worked Example: a ledgered /warehouse fetch surface

This continues the /warehouse route from Fetch Lanes: Deep Dive #81.

We will:

  1. define a receipt schema,
  2. write a ledger hook with bounded history,
  3. wrap useRenderFetch to emit receipts,
  4. add a "ledger panel" to the shell,
  5. add tests that assert against rendered receipts (route-flow style).

Step 1: Receipt schema (stable, render-safe)

export function createFetchReceipt(input: {
  laneId: string;
  laneKey: string;
  requestKey: string;
  url: string;
  ok: boolean;
  status: number;
  ms: number;
  cache: string;
  dedupe: string;
  note?: string;
}) {
  return {
    id: `${input.laneKey}:${input.requestKey}`,
    at: Date.now(),
    ...input,
  };
}

The receipt id must be stable across refactors. That is what turns it into a contract.

Step 2: Bounded ledger (evidence without runaway growth)

import { useLiveState } from "react";

export function useFetchLedger(namespace: string) {
  return useLiveState(namespace, {
    laneId: "fast",
    laneKey: "",
    receipts: [],
    cap: 60,
    lastReceiptId: "",
  });
}

export function pushReceipt(ledger: any, receipt: any) {
  const cap = ledger.read("cap");
  const next = [...ledger.read("receipts"), receipt].slice(-cap);
  ledger.write("receipts", next);
  ledger.write("lastReceiptId", receipt.id);
}

Step 3: Emit receipts from render (render effect posture)

We intentionally emit receipts in a render-visible lifecycle to keep the route story readable.

import { useRenderFetch, useRenderEffect } from "react";
import { createFetchReceipt, pushReceipt } from "./fetch-ledger";

export function useLedgeredFetch(opts: {
  ledger: any;
  lane: any;
  requestKey: string;
  url: string;
}) {
  const { ledger, lane, requestKey, url } = opts;

  const startedAt = Date.now();
  const result = useRenderFetch(url, {
    cache: lane.cache,
    dedupe: lane.dedupe,
    laneId: lane.laneId,
    ledgerKey: lane.laneKey,
  });

  useRenderEffect(() => {
    const ok = Boolean(result && result.ok !== false);
    const status = Number(result?.status ?? 200);

    const receipt = createFetchReceipt({
      laneId: lane.laneId,
      laneKey: lane.laneKey,
      requestKey,
      url,
      ok,
      status,
      ms: Date.now() - startedAt,
      cache: lane.cache,
      dedupe: lane.dedupe,
      note: ok ? "ok" : "soft-fail",
    });

    ledger.write("laneId", lane.laneId);
    ledger.write("laneKey", lane.laneKey);
    pushReceipt(ledger, receipt);
  });

  return result;
}

This is the entire philosophy in one place:

  • you still fetch in render,
  • but now you also write render-visible evidence.

Step 4: Render a ledger panel (DOM as your "debug port")

export function FetchLedgerPanel({ ledger }: any) {
  const receipts = ledger.read("receipts");
  const laneId = ledger.read("laneId");
  const laneKey = ledger.read("laneKey");

  return (
    <aside data-ledger="fetch" data-lane={laneId} data-lane-key={laneKey}>
      <header>
        <strong>Fetch Ledger</strong>{" "}
        <span className="text-sm text-[color:var(--docs-muted)]">
          {receipts.length} receipts
        </span>
      </header>

      <ol>
        {receipts.map((r: any) => (
          <li key={r.id} data-receipt={r.id} data-ok={String(r.ok)}>
            <strong>{r.requestKey}</strong>{" "}
            <span className="text-sm text-[color:var(--docs-muted)]">
              {r.ms}ms {r.cache}/{r.dedupe} status:{r.status}
            </span>
          </li>
        ))}
      </ol>

      <pre data-ledger-json>{JSON.stringify(ledger.read(), null, 2)}</pre>
    </aside>
  );
}

In this model, "debugging" is just "reading the UI".

Step 5: Use ledgered fetch in a track (orders list)

import { useLedgeredFetch } from "../useLedgeredFetch";

export default function OrdersTrack({ lane, ledger }: any) {
  const orders = useLedgeredFetch({
    lane,
    ledger,
    requestKey: "orders:list",
    url: "/api/orders?limit=50",
  });

  return (
    <section data-track="orders">
      <h2>Orders</h2>
      <ul>
        {orders.items.map((o: any) => (
          <li key={o.id} data-order={o.id}>
            {o.number}
          </li>
        ))}
      </ul>
    </section>
  );
}

Step 6: Test the route story (rendered receipts)

You don’t need a special testing framework to benefit. The posture is:

  • navigate to the route,
  • perform a track switch,
  • assert that receipts exist in the DOM under stable keys.
// pseudo-test (route-flow style)
test("warehouse emits receipts for orders", async () => {
  const app = await mountRoute("/warehouse");
  await app.click("button", { text: "orders" });

  await app.expect("[data-ledger='fetch']").toExist();
  await app.expect("[data-receipt='warehouse.orders:orders:list']").toExist();
});

The ids are public API. That’s what makes the test durable.

Operational guidance

Use ledgers when you need:

  • evidence for migrations (compare lanes across versions),
  • deterministic debug in production (rendered receipts),
  • compatibility across routers (file vs component routes),
  • and a place to put "soft failures" that still let the UI move.

Checklist

  • Receipts are stable and render-safe.
  • Ledger history is bounded (cap).
  • Emission happens in a render-visible lifecycle.
  • The ledger is rendered into the DOM.
  • Tests assert against ledger evidence, not internal fetch helpers.