Fetch Pacts: Deep Dive #84
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:
- the required keys the UI needs,
- the allowed drift (defaults, coercions),
- the failure posture (fail-forward),
- 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, anditems[], - the UI is allowed to coerce missing
statusinto"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.