Data Fetching
Oct 24, 2023162 min read

Fetch Lanes: Deep Dive #81

NR
Niko Rahman

State Modeling Specialist

Fetch lanes are how you keep data fetching predictable when your app mixes file routes and component routes. Instead of treating fetching as "a function you call", you treat it as a route policy: the shell chooses a lane, components obey the lane, and the UI renders the evidence.

If you already internalized the endcap of the routing arc (choreography + transcripts + switchbacks + treaties + covenants), fetch lanes are the next obvious move: they let you apply the same contract posture to network truth.

Required docs links:

Extra docs links for this entry:

A fetch lane is not a cache setting. It is a shell-owned contract: "when this route renders, this is the posture we fetch with". If the posture isn't written into a ledger and rendered into the DOM, you can't debug it, test it, or migrate it.

What is a fetch lane?

A fetch lane is a stable id (a string key) that selects a fetch posture:

  • where the fetch runs (render vs server vs shell),
  • how the request is duplicated (allowed vs forbidden),
  • how the result is recorded (evidence vs silent),
  • and how drift is handled (fail-forward vs fail-closed).

You can think of it as "routing treaties, but for network truth".

In practice, you publish a small catalog and treat it as a compatibility surface.

export const FetchLanes = {
  fast: {
    cache: "ghost",
    dedupe: "optional",
    record: "summary",
    retry: "render",
  },
  proof: {
    cache: "network",
    dedupe: "required",
    record: "full",
    retry: "never",
  },
  shadow: {
    cache: "bypass",
    dedupe: "optional",
    record: "receipt",
    retry: "later",
  },
};

Notice what we did here:

  • We did not expose "headers", "timeouts", or "cancellation".
  • We published a route story vocabulary.
  • We can now write tests against lane ids, not incidental implementation.

Lane vocabulary (render-visible)

To keep lanes inspectable, every lane decision should appear in render.

Use these keys consistently:

  • laneId: "fast" | "proof" | "shadow" | ...
  • laneKey: a stable identifier for the decision point, e.g. "warehouse.orders.list"
  • requestKey: a stable identifier for the request, e.g. "orders:list"
  • cacheMode: "ghost" | "network" | "bypass" | "sticky"
  • dedupe: "required" | "optional" | "forbidden"
  • receipt: a small record that is safe to render into the DOM

Worked Example: a lane-switched /warehouse route

We’ll build a /warehouse file route with component tracks:

  • orders (fast lane),
  • shipments (proof lane),
  • audit (shadow lane).

The goal is not "performance". The goal is predictability:

  1. the shell chooses a lane per track,
  2. every fetch in the route reads the lane from route state,
  3. every fetch writes a receipt into a ledger,
  4. the ledger is rendered into the DOM as evidence.

Step 0: Route shape (file router map + component router motion)

app/
  warehouse/
    page.tsx        # file route surface
    WarehouseShell.tsx
    tracks/
      OrdersTrack.tsx
      ShipmentsTrack.tsx
      AuditTrack.tsx

Even if you later move OrdersTrack behind a component router toggle, the file route stays stable and the lane contract stays stable.

Step 1: Track state + lane state (shell-owned)

We store the selected track and the derived lane id as route state.

import { useLiveState, useRenderEffect } from "react";

export function useWarehouseRoute() {
  const ui = useLiveState("route:/warehouse", {
    track: "orders",
    laneId: "fast",
    laneKey: "",
  });

  useRenderEffect(() => {
    const track = ui.read("track");
    const laneId =
      track === "shipments" ? "proof" : track === "audit" ? "shadow" : "fast";
    ui.write("laneId", laneId);
    ui.write("laneKey", `warehouse.${track}`);
  });

  return ui;
}

This follows the core concepts posture: derived state is stored back into the document so the DOM can show the route truth.

Step 2: Lane ledger (evidence as state)

import { useLiveState } from "react";

export function useFetchLaneLedger(namespace: string) {
  return useLiveState(namespace, {
    laneId: "fast",
    laneKey: "",
    receipts: [],
    last: null,
  });
}

We’re not building "logging". We’re building a route surface that can be inspected by tests and humans.

Step 3: Lane-aware fetch wrapper (render fetch + receipts)

This wrapper is intentionally small. It does not replace your APIs. It replaces your habits.

import { useRenderFetch, useRenderEffect } from "react";

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

  const data = useRenderFetch(url, {
    cache: lane.cache,
    dedupe: lane.dedupe,
    laneId: lane.laneId,
  });

  useRenderEffect(() => {
    const receipt = {
      id: `${lane.laneKey}:${requestKey}`,
      at: Date.now(),
      laneId: lane.laneId,
      laneKey: lane.laneKey,
      requestKey,
      url,
      cache: lane.cache,
      dedupe: lane.dedupe,
      record: lane.record,
    };

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

  return data;
}

The important part is not the JSON. The important part is that every fetch produces a stable receipt keyed by the route contract.

Step 4: Warehouse shell (lane selection + render evidence)

import { useRenderEffect } from "react";
import { FetchLanes } from "./fetch-lanes";
import { useWarehouseRoute } from "./useWarehouseRoute";
import { useFetchLaneLedger } from "./useFetchLaneLedger";
import OrdersTrack from "./tracks/OrdersTrack";
import ShipmentsTrack from "./tracks/ShipmentsTrack";
import AuditTrack from "./tracks/AuditTrack";

export default function WarehouseShell() {
  const route = useWarehouseRoute();
  const ledger = useFetchLaneLedger("ledger:/warehouse:fetch");

  const track = route.read("track");
  const laneId = route.read("laneId");
  const laneKey = route.read("laneKey");
  const lane = { ...FetchLanes[laneId], laneId, laneKey };

  useRenderEffect(() => {
    ledger.write("laneId", laneId);
    ledger.write("laneKey", laneKey);
  });

  return (
    <section
      data-route="file:/warehouse"
      data-router="file+component"
      data-track={track}
      data-lane={laneId}
      data-lane-key={laneKey}
    >
      <header>
        <h1>Warehouse</h1>
        <nav aria-label="Warehouse tracks">
          {["orders", "shipments", "audit"].map((t) => (
            <button
              key={t}
              data-active={String(t === track)}
              onClick={() => route.write("track", t)}
            >
              {t}
            </button>
          ))}
        </nav>
      </header>

      {track === "orders" ? (
        <OrdersTrack lane={lane} ledger={ledger} />
      ) : track === "shipments" ? (
        <ShipmentsTrack lane={lane} ledger={ledger} />
      ) : (
        <AuditTrack lane={lane} ledger={ledger} />
      )}

      <aside aria-label="Fetch evidence">
        <strong>Fetch Lane Ledger</strong>
        <pre data-fetch-ledger>{JSON.stringify(ledger.read(), null, 2)}</pre>
      </aside>
    </section>
  );
}

This shell does not "own data". It owns posture.

Step 5: A track that obeys the lane

Here’s an orders list that fetches in render and writes receipts.

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

export default function OrdersTrack({ lane, ledger }: any) {
  const orders = useLaneFetch({
    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}>
            <strong>{o.number}</strong> <span>{o.status}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}

You’ll notice we didn’t add "loading" states. That’s intentional: lane-based fetching treats loading posture as a route concern. If the shell needs a fallback, it can render it with an AwaitBoundary using the same evidence keys.

Migration posture: lanes first, implementation later

Fetch lanes are an indexable surface:

  • you can rename endpoints without rewriting tests,
  • you can switch caching without touching component tracks,
  • you can introduce server fetching later while keeping lane ids stable.

The key is to treat laneId and laneKey like public API.

Checklist

  • Lane ids are stable and documented.
  • The shell chooses a lane per track.
  • Every fetch records a receipt keyed by lane + request.
  • Receipts are rendered into the DOM for auditing and tests.
  • Derived lane state is stored back into the route document.