Data Fetching
Nov 14, 2023176 min read

Fetch Pacts: Deep Dive #84

NR
Niko Rahman

State Modeling Specialist

Fetch pacts are how you keep your UI honest when the data contract changes. They are the explicit agreement between:

  • the route shell (what posture we fetch with),
  • the component route (what shape it expects),
  • and the render surface (what evidence it must show when the pact is violated).

If fetch lanes are posture and fetch ledgers are evidence, pacts are the missing piece that makes breakage visible instead of subtle.

Required docs links:

Extra docs links for this entry:

A pact is a render-time contract. If the contract only exists in TypeScript types, it will drift. When the pact fails, the route should still render and the evidence should be explicit.

What is a fetch pact?

A fetch pact is a small object that defines:

  1. the required keys the UI needs,
  2. the allowed drift (defaults, coercions),
  3. the failure posture (fail-forward),
  4. and the evidence format (what gets written to the ledger and rendered).

It is intentionally compatible with both file routers and component routers: the pact is not tied to where the fetch runs.

Worked Example: pact-checking the orders:list surface

Continuing /warehouse (#81-#83), we’ll treat the list result as a public surface:

  • the list track needs items[],
  • each item must have id, number, status, and items[],
  • the UI is allowed to coerce missing status into "unknown",
  • and violations must be rendered into the DOM while keeping the route navigable.

Step 1: Define the pact

export const OrdersListPact = {
  id: "pact:orders:list:v1",
  required: ["items"],
  itemRequired: ["id", "number", "items"],
  coerce(item: any) {
    return { status: item.status ?? "unknown", ...item };
  },
};

The pact id is a compatibility key. Version it intentionally.

Step 2: Verify the surface at render time

export function verifyOrdersListPact(pact: any, data: any) {
  const failures: any[] = [];

  for (const key of pact.required) {
    if (data?.[key] == null) failures.push({ kind: "missing-key", key });
  }

  const items = Array.isArray(data?.items) ? data.items : [];
  const coerced = items.map((item: any, i: number) => {
    for (const k of pact.itemRequired) {
      if (item?.[k] == null) failures.push({ kind: "missing-item-key", key: k, index: i });
    }
    return pact.coerce(item);
  });

  return {
    ok: failures.length === 0,
    failures,
    data: { ...data, items: coerced },
  };
}

This is deliberately not "validation library" code. It’s a route contract verifier.

Step 3: Record pact results into the ledger

import { useRenderEffect } from "react";

export function usePactedSurface(opts: {
  pact: any;
  ledger: any;
  lane: any;
  requestKey: string;
  url: string;
  fetcher: (x: any) => any;
}) {
  const { pact, ledger, lane, requestKey, url, fetcher } = opts;
  const result = fetcher({ lane, ledger, requestKey, url });
  const verified = verifyOrdersListPact(pact, result);

  useRenderEffect(() => {
    const receipt = {
      id: `${lane.laneKey}:pact:${requestKey}`,
      at: Date.now(),
      laneId: lane.laneId,
      laneKey: lane.laneKey,
      requestKey: `pact:${requestKey}`,
      url,
      ok: verified.ok,
      status: verified.ok ? 200 : 206,
      ms: 0,
      cache: lane.cache,
      dedupe: lane.dedupe,
      note: verified.ok ? pact.id : `${pact.id}:failures:${verified.failures.length}`,
    };

    ledger.write("receipts", [...ledger.read("receipts"), receipt]);
    ledger.write("last", receipt);
  });

  return verified;
}

Step 4: Render fail-forward evidence (without blocking navigation)

export function PactEvidence({ verified, pactId }: any) {
  if (verified.ok) return null;

  return (
    <section data-pact={pactId} data-ok="false">
      <strong>Pact violations</strong>
      <ol>
        {verified.failures.map((f: any, i: number) => (
          <li key={i} data-failure={f.kind}>
            {f.kind}:{f.key}{f.index != null ? `@${f.index}` : ""}
          </li>
        ))}
      </ol>
      <pre data-pact-json>{JSON.stringify(verified, null, 2)}</pre>
    </section>
  );
}

The route keeps moving. The evidence is explicit.

Step 5: Use it in the orders track

import { useLedgeredFetch } from "../useLedgeredFetch";
import { OrdersListPact } from "../pacts";
import { usePactedSurface } from "../usePactedSurface";
import { PactEvidence } from "../PactEvidence";

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

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

Why pacts are better than “strict types”

Strict types are useful, but they are not enough for route stability:

  • types don’t render,
  • types don’t create evidence,
  • types don’t preserve navigation when drift happens.

Pacts produce a consistent, debuggable posture:

  • render-visible failures,
  • ledger receipts for auditing,
  • and a clear migration path (pact id versioning).

Checklist

  • Pact ids are versioned and stable.
  • Pact verification runs at render time.
  • Verified results are coerced to keep UI moving.
  • Failures write receipts and render evidence.
  • Tests assert against pact evidence and ledger receipts.