use-lane
v0.8.0
Published
Promise-first, transition-native data fetching for React 19 — a minimal data layer that keeps keyed promises in React state while React owns the UI.
Maintainers
Readme
use-lane
Promise-first. Transition-native.
Lane is a minimal data layer for React 19. It keeps each keyed read's promise in React state, so Suspense reads it, transitions replace it, and the screen you're looking at stays live while the next data loads. No spinner flash, no torn UI, no parallel query state machine.
React 19 ships the primitives to render async data — use(promise) for data,
Suspense for loading, Error Boundaries for errors, transitions for non-blocking
updates, and useOptimistic / useActionState for mutations. It doesn't ship
the small coordination layer underneath: stable promise identity by key, one
shared request across components, and a way to replace that promise after the
source changes. Lane is exactly that layer — and nothing React already provides.
const { promise } = useLane({
key: ["user", id],
loader: ({ signal }) => fetchUser(id, signal),
});
const { data: user } = use(promise); // Suspense owns loading, Error Boundaries own errorsWhy Lane
Libraries like SWR and TanStack Query own a resolved-value cache plus their own loading/error/status objects, optimistic patches, and mutation helpers. Lane takes the opposite split: the promise is the state. Lane owns only the promise identity and lifecycle behind each key; React owns the UI state it was designed to own.
- Promise-first by construction. Lane keeps each key's current promise in
React state instead of reading resolved query state from an external store.
use(promise)is the only read path: Suspense owns the first load, an Error Boundary owns the first failure, and a refresh swaps in the next promise. - Promise replacement is transition-native. Invalidation and revalidation
replace the promise through a transition. Key changes compose with
startTransitionoruseDeferredValue, so the same interruptible transition model drives the rest of your app and keeps the current screen live. - One mental model. Mutate the source, invalidate the read, render from the next promise — the same model you already use next to Server Components.
- No parallel state machine. No
isLoading/isError/statusfields.use(promise)gives you{ data }(and arefreshErroronly when a refresh fails over existing data); Suspense and Error Boundaries do the rest. - No mutation helper, by design. Mutations stay in React primitives, so optimistic UI lives next to the action that triggered it instead of in a global cache that needs rollback semantics.
- Minimal on purpose. A typical
LaneProvider+useLaneimport is about 3.4 kB minified and Brotli-compressed, and importing every export costs 4.7 kB — that is the whole ceiling. Lane stays small because it does not reimplement the UI state machine React already ships.
Requirements
React 19.2+ (Lane uses useEffectEvent). React is a peer dependency.
Lane leans on React core (use, Suspense, useTransition, useEffectEvent),
never react-dom, so it runs in any React renderer — the browser, React
Native, an Ink CLI, or your own. See
Environments
for CLI / React Native setup.
Install
npm install use-lane
# or: pnpm add use-lane
# or: yarn add use-laneQuick start
1. Wrap your client tree in a LaneProvider.
"use client";
import { LaneProvider } from "use-lane";
export function Providers({ children }: { children: React.ReactNode }) {
return <LaneProvider>{children}</LaneProvider>;
}2. Read with useLane and unwrap with use. Lane returns the promise; a
Suspense boundary owns the loading state and an Error Boundary owns the
initial-load failure.
"use client";
import { Suspense, use } from "react";
import { useLane } from "use-lane";
function Profile({ userId }: { userId: string }) {
const { promise } = useLane({
key: ["user", userId],
loader: async ({ signal }) => {
const res = await fetch(`/api/users/${userId}`, { signal });
if (!res.ok) throw new Error("Failed to load user");
return (await res.json()) as User;
},
});
const { data: user } = use(promise);
return <h1>{user.name}</h1>;
}
export function UserProfile({ userId }: { userId: string }) {
return (
<Suspense fallback={<p>Loading…</p>}>
<Profile userId={userId} />
</Suspense>
);
}3. Converge after a mutation by invalidating the source. Mounted readers
re-read through a transition; isInvalidationPending tells you it is happening.
"use client";
import { useLaneInstance } from "use-lane";
function RenameButton({ userId }: { userId: string }) {
const lane = useLaneInstance();
async function rename(name: string) {
await fetch(`/api/users/${userId}`, {
method: "PATCH",
body: JSON.stringify({ name }),
});
// Source changed → re-read the affected key. React renders the next promise.
lane.invalidate(["user", userId]);
}
return <button onClick={() => rename("Ada")}>Rename</button>;
}Core concepts
- Transition-native re-reads. Updates run through
useTransition, so the previous UI stays mounted and interactive while the next promise resolves —isInvalidationPendingandisBackgroundPendingtell you which is in flight. Pair a key withuseDeferredValuefor search and filter UIs. (Initial loads with no prior data still suspend to a Suspense fallback.) - Keys are structural arrays (
["task", id]). They are matched exactly, or byprefix/ predicate for scoped operations.Datesegments are supported. - Invalidation-driven re-reads.
invalidateclears the cached promise and notifies mounted readers, which create the next promise from their current loader. Explicit (transition) and automatic (focus/mount/ polling, reported asisBackgroundPending) re-reads are kept separate. - Stale-on-error. A failed refresh keeps serving the last fulfilled value;
the promise resolves to
{ data, refreshError }, souse(promise)surfaces the stale data and the error together. Only an initial load (no previous value) rejects the promise and reaches the Error Boundary. - Authoritative publication.
set/updatepublish server-confirmed data to exact keys the client owns;LaneHydrationpublishes RSC- or router-loaded data and overwrites authoritatively on navigation. - Ownership is decided per key, and enforced. A key the client fetches is
client-owned. A key a publication seeds is not: read it with
laneRead<T>({ key, loader: external })— a loader that waits for the publication instead of fetching — and the client mutation surface (set/update/invalidate/remove) throws on it. Mutations go back through the owner (Server Action → revalidate → republish); immediacy isuseOptimisticover the read value. - Lifecycle built in. Garbage collection (
gcTime, default 5 min),retry/retryDelay, andrefetchOnFocus/refetchOnMount/refetchOnReconnectrevalidation. Polling is userland — a self-scheduledinvalidate. - Optimistic UI stays local. Lane ships no mutation helper; use
useOptimistic/useActionStatein the component that owns the action. - A read can be one value.
laneRead({ key, loader, ...options })colocates a read the way react-query'squeryOptions()does. The loaded type rides on the key it hands back (like react-query'sDataTag), soset/updateare type-checked from the key alone and a mutation path never imports a fetcher. - The session lives on the lane, not in the read. Declare it once
(
LaneRegister), supply it at the provider, and every loader is handed it asmeta. A read's arguments stay exactly what decides its key, so.keyis reachable from a mutation module or a Server Component — no parallel map of bare keys.
API at a glance
| Export | Purpose |
| --- | --- |
| LaneProvider | Provides a Lane instance to the tree; wires focus / reconnect revalidation via a pluggable eventSource (browser default; React Native / CLI / custom). |
| useLane(read) | Read a key. Returns { promise, isInvalidationPending, isBackgroundPending, invalidate }; use(promise) yields { data, refreshError }. |
| useLanePromise(read) | Thin wrapper returning just promise. |
| laneRead({ key, loader, …options }) | Colocate a read's key, loader, and options in one value — react-query's queryOptions() for Lane. Reads take the whole thing (useLane, useLanesAll, prefetch); entry operations take its key. |
| laneKey<T>(key) | A key that carries what its entry holds, so set / update through it are type-checked — no loader needed. |
| LaneRegister | Declare what loaders are handed besides the key (a session, a tenant, an API client). Supplied as <LaneProvider loaderMeta={…}>, received as loader({ meta }). react-query's Register, with the value on the lane so it is mandatory. |
| laneSnapshot(read, data) | One hydration entry, type-checked: T comes from the read's key, so a seed can't pair a key with the wrong data. |
| useInfiniteLane(read) | A cursor-paginated list under one key. Returns { promise, loadMore, … }; use(promise) yields { pages, params, hasNext }. Colocate it with infiniteLaneRead. |
| useLaneInstance() | The current Lane instance, for invalidate / set / update / remove from event handlers. |
| createLane(options?) | Create a Lane instance manually (e.g. to share one across providers or seed on the server); accepts { gcTime }. |
| LaneHydration | Publish a payload of snapshots as authoritative values — RSC props or a client router's loader data. |
| external | The loader for a key its owner publishes: waits for the publication, never fetches. laneRead<T>({ key, loader: external }). |
Lane instance methods: invalidate / invalidateAll, set, update /
updateAll, remove / removeAll — all keyed; set / update are checked
when given a typed key. useLane options: staleTime, whenStale,
retry, retryDelay, refetchOnFocus, refetchOnMount, refetchOnReconnect.
createLane options: gcTime. Loaders receive { key, signal, current }, where
current is the entry's last fulfilled value.
See the API reference for full signatures and semantics.
Documentation
- API reference — every export, option, and behavior.
- Migrating from React Query / SWR — the mental-model map and the migration gotchas.
- Supported architectures — the per-key ownership rule: RSC props, published (
external), or client-owned. - Environments — CLI (Ink), React Native, and other React renderers.
- Design notes — why Lane is shaped this way.
Agent skill
This package ships an Agent Skills skill, so AI coding
agents get use-lane-aware guidance that is version-locked to the installed
package. It lives at skills/use-lane/SKILL.md and is self-contained — the
full documentation is bundled alongside it as references.
If your project uses an AI agent, point it at the skill from your AGENTS.md /
CLAUDE.md:
## Agent skills
Before editing React data-loading code, read the use-lane skill at
`node_modules/use-lane/skills/use-lane/SKILL.md` — use it for Suspense, `use()`,
transitions, invalidation, refetching, optimistic UI, or React Query / SWR
migration work.License
MIT © Kento Moriwaki
