Skip to main content

@web-ts-toolkit/access-router-react

React hooks for @web-ts-toolkit/access-router-client model services.

createModelHooks({ modelService }) binds one ModelService to eight hooks covering read, list, count, distinct, create, update, upsert, and delete. Each hook instance owns its own local state — there is no shared cache, no deduplication, no invalidation, and no retry. Two components calling useRead({ id: '1' }) against the same ModelService issue two independent requests and store two independent copies of the result. If you need cache orchestration, layer these services underneath a query library.

Installation​

npm install react @web-ts-toolkit/access-router-react @web-ts-toolkit/access-router-client

Peer dependencies: react ^18 || ^19 and @web-ts-toolkit/access-router-client. The package's own test suite runs a React 18 verification lane alongside the React 19 primary lane. Published builds target ES2022; direct Node consumers require Node >=22, matching the required client and utility packages. The former >=20 declaration understated that dependency floor. Browser apps can bundle with an ES2022-capable toolchain. Compatibility CI exercises packed CJS/ESM hooks and strict NodeNext/Bundler consumers on Node 22.0.0 with matching React 18/19 types; builds/install tools retain the newer repository runtime. The supported lane asserts direct dependency pins and archives resolved trees/lockfiles per run. The package maintainer updates pins with dependency changes; a separate floating canary is non-blocking.

Factory​

import { createAdapter } from '@web-ts-toolkit/access-router-client';
import { createModelHooks } from '@web-ts-toolkit/access-router-react';

const adapter = createAdapter({ baseURL: 'https://api.example.com' });

const organizationService = adapter.createModelService<Organization>({
modelName: 'Organization',
basePath: 'organizations',
});

const { useRead, useList, useCount, useDistinct, useCreate, useUpdate, useUpsert, useDelete } = createModelHooks({
modelService: organizationService,
});

Call createModelHooks once, outside any component, with a ModelService<T> from adapter.createModelService<T>({ modelName, basePath }). The returned hooks are bound to that one service for their lifetime; do not call the factory inside a component. Organization is your own model interface extending the client's Document.

What It Exposes​

  • createModelHooks(...) — the factory.
  • query hooks: useRead, useList, useCount, useDistinct
  • mutation hooks: useCreate, useUpdate, useUpsert, useDelete
  • hook option and result types for the query and mutation APIs (e.g. UseReadQueryOptions, UseCreateMutateResult)
  • requestKeyFor(value) and RequestKeyError — the public dependency-key helper
  • projection-aware result helpers: ProjectedShape, ProjectedShapeArray, ProjectedModelResponse, ProjectedListModelResponse

Quick Start​

import { createAdapter } from '@web-ts-toolkit/access-router-client';
import { createModelHooks } from '@web-ts-toolkit/access-router-react';

const adapter = createAdapter({ baseURL: 'https://api.example.com' });

const organizationService = adapter.createModelService<Organization>({
modelName: 'Organization',
basePath: 'organizations',
});

const { useList, useRead, useCreate, useUpdate, useDelete } = createModelHooks({
modelService: organizationService,
});

function OrganizationList() {
const { data, isLoading, error } = useList({
listParams: { pageSize: 20 },
});

if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;

return (
<ul>
{data.map((org) => (
<li key={org._id}>{org.name}</li>
))}
</ul>
);
}

Query Hooks​

useRead, useList, useCount, and useDistinct auto-fetch when their enabled flag is true (the default) and the key inputs are present. They each expose query(...), refetch(), and reset() alongside the result state.

useRead​

const { data, isLoading, isFetching, error, query, refetch, reset } = useRead({
id: 'org_123',
advanced: true,
select: ['name', 'status'],
});

Important options:

  • id controls auto-fetching. Set enabled: false (or remove id) to disable.
  • advanced: true switches to readAdvanced(...), which forwards select, populate, sort, include, and tasks.
  • basicOptions, advancedOptions, enabled, initialData, requestConfig, onSuccess, onError, and onSettled control request behavior.
  • query(id, { signal }) re-runs the read imperatively; refetch() re-runs with the current options. Both return a promise that rejects with a ServiceError on failure.

useList​

const { data, previousData, totalCount, isLoading, isFetching, error, query, refetch, reset } = useList({
listParams: { pageSize: 20 },
filter: { status: 'active' },
advanced: true,
sort: { name: 1 },
keepPreviousData: true,
});

Important options:

  • listParams drives basic list requests
  • filter is used for advanced lists
  • keepPreviousData preserves the last resolved list during a replacement request (see the Lifecycle section for the full capture/clear rules)
  • sort, select, populate, include, tasks, basicOptions, and advancedOptions map directly to client service arguments

useCount​

const { data, isLoading, error, query, refetch, reset } = useCount({
advanced: true,
filter: { status: 'active' },
});

Use advanced: true when you need a filtered count.

useDistinct​

const { data, isLoading, error, query, refetch, reset } = useDistinct({
field: 'status',
conditions: { organizationId: 'org_123' },
});

If conditions is empty, the hook falls back to the basic distinct(...) route.

Mutation Hooks​

useCreate, useUpdate, useUpsert, and useDelete expose mutate(...), isPending, error, and reset(). The first three also expose data (the last returned projected model). Each mutate(...) call returns a promise that resolves the response, or rejects with a ServiceError on failure. Mutation input types are inferred from the bound ModelService<T, TCreateInput, TUpdateInput, TUpsertInput> generics. useCreate().mutate(...) is intentionally single-record-only and rejects array input; call modelService.create([...]) directly when you need bulk create.

useCreate​

const { data, isPending, error, mutate, reset } = useCreate({
advanced: true,
select: ['_id', 'name'],
});

await mutate({ name: 'Northwind Labs' });

useUpdate​

const { data, isPending, error, mutate } = useUpdate();

await mutate('org_123', { status: 'active' });

useUpsert​

const { data, isPending, error, mutate } = useUpsert();

await mutate({ _id: 'org_123', name: 'Northwind Labs' });

useDelete​

const { isPending, error, mutate } = useDelete();

await mutate('org_123');

Shared mutation behavior:

  • advanced: true switches to the corresponding advanced client method when available.
  • mutate(...) performs the request and returns an awaitable promise.
  • isPending is true while any invocation is in flight (see Concurrent Mutations).
  • reset(), onSuccess, onError, and onSettled are available on every mutation hook (see Lifecycle).

Lifecycle​

The hooks share one unified query lifecycle and one unified mutation lifecycle.

Loading flags​

  • isFetching is true while any query request is in flight.
  • isLoading is true only while no settled data exists for this hook instance. Once the first successful response lands, subsequent refetch() calls set isFetching but not isLoading, so you can distinguish background fetches from the first load.
  • isPending (mutations) is true while any mutation invocation is in flight — overlapping mutations keep isPending true until the active count reaches zero.

Failure handling​

A resolved success: false response is treated as a hook-level failure. The hooks never invoke onSuccess for a failed response, never populate data with a failure payload, and surface a ServiceError carrying message, status, raw, and headers via error, onError, and the rejected mutate/query/refetch promise. A thrown onSuccess/onError/onSettled callback is rethrown asynchronously as an uncaught microtask and never converts a successful request into a hook-level error.

Cancellation​

A dependency change, query()/refetch() invocation, or unmount aborts the in-flight request and replaces it. Cancellation is authoritative: an aborted request never writes error, never fires onError or onSettled, and converges isLoading/isFetching/isPending to false. The hooks decide cancellation on signal.aborted rather than instanceof DOMException, so axios CanceledError, Error('Canceled') with code: 'ERR_CANCELED', or any other transport-specific cancellation shape is handled uniformly.

const controller = new AbortController();
const pending = query('org_123', { signal: controller.signal });
controller.abort(); // cancels the in-flight manual request while it is still pending

try {
await pending;
} catch (error) {
console.error('manual query cancelled', error);
}

The hook's requestConfig.signal is composed with the per-call query() options.signal and the hook-owned controller signal, then forwarded to the underlying client request via a fresh shallow copy of requestConfig. That one effective signal also drives hook-side cancellation classification after resolve/reject. Aborting any source cancels the effective request; the caller's requestConfig object, its headers, and other fields are not mutated.

previousData lifecycle (useList only)​

previousData is opt-in via keepPreviousData: true. When enabled:

  • Captured at the start of a replacement request, only if the hook has previously produced at least one settled list response (the first request has nothing to preserve).
  • Cleared on every terminal path: success (applyResult), failure (onFailed), abort (onAborted), disable / id-removed (onDisabled), and reset().
  • The first request after a reset() is again treated as "no prior settlement" — hasSettled is cleared on reset — so the next pending request does not capture stale state.

reset()​

reset() is a synchronous state-clear, not a cancellation:

  • Query reset() clears data/error/isLoading/isFetching (and previousData for useList) and invalidates the current query owner's right to publish settlement. A pre-reset success, failure, rejection, or abort may still finish at the transport layer, but it is stale for hook state and callbacks.
  • Mutation reset() clears data/error and bumps the latest-invocation token. Any already-running mutation loses its claim on the shared data/error state — when it later settles, its per-invocation onSuccess/onSettled still fire, but it cannot repopulate the cleared data/error. isPending remains true until the active count reaches zero; reset does not implicitly cancel.

After query reset(), isFetching reflects authoritative hook activity, not physical transport activity, so it becomes false immediately even if the abandoned request is still finishing underneath. If you need to cancel an in-flight query, drop id/listParams or set enabled: false rather than calling reset.

refetch() and query()​

Both reuse the unified lifecycle: shared isLoading/isFetching/error writes, shared callback observers, shared abort manager. They return an awaitable promise that resolves with the response or rejects with the ServiceError. A trailing .catch suppression lets fire-and-forget callers skip await without leaking an unhandled rejection.

Concurrent Mutations​

A mutation hook may be invoked more than once in flight — a caller clicking "Save" twice, a list-reordering UI firing two updates, a retry button hit before the first attempt finishes. The contract:

  • Active-count isPending: isPending is true while any invocation is in flight and stays true until the active count reaches zero. The first invocation's finally cannot clear isPending while a second is still pending.
  • Latest-invocation-wins for data and error: an older invocation that settles after a newer one started (out-of-order completion) still resolves its own promise and fires its own onSuccess/onSettled observers, but cannot overwrite the newer invocation's already-written data or error.
  • No implicit cancellation: a newer invocation does not abort an older one; they settle independently. The hook truthfully reports isPending === true until every pending mutation completes.
function Save() {
const { mutate, isPending } = useUpdate({ advanced: true, select: ['name'] as const });

const saveTwice = async () => {
try {
const [firstResult, secondResult] = await Promise.all([
mutate('org_1', { name: 'A' }),
mutate('org_1', { name: 'B' }),
]);
// Promise.all preserves invocation order. Hook state still follows the latest invocation.
console.log(firstResult.data?.name, secondResult.data?.name);
return secondResult.data;
} catch (error) {
// Hook error state still surfaces the failure; handling here avoids an unhandled rejection.
console.error('Save failed', error);
return undefined;
}
};

return (
<button disabled={isPending} onClick={() => void saveTwice().catch(() => undefined)}>
Save twice
</button>
);
}

Projection Typing​

useRead, useList, useCreate, useUpdate, and useUpsert accept a literal select:

const { data } = useRead({
id: 'org_123',
advanced: true,
select: ['name', 'status'],
});

if (data) {
const name: string = data.name;
const status: string | undefined = data.status;
const id: string | undefined = data._id;
}

A literal select narrows data, onSuccess(result)/onSettled(result, …) callbacks, manual query()/refetch() response payloads, and mutation mutate() return promises uniformly. Acceptable select forms: a literal tuple (['name', 'status'] as const, recommended), a literal string ('name'), or a { name: 1; age: -1 } object. Omitted properties become T[key] | undefined rather than definitely-present. On the read/list/update hooks the projection reaches the server on both paths — as a ?select= query value on the basic path and as body select with advanced: true. The basic create/upsert APIs accept no select argument, so a literal select on those two hooks still requires advanced: true to actually reach the server's narrowing code path.

Only an omitted select keeps the full required model. A supplied-but-indeterminable select (a broad string/string[] variable or an exclusion-only object such as { status: -1 }) marks every field optional, and a union select (e.g. readonly ['name'] | readonly ['status']) keeps each alternative's own required/optional contract instead of merging keys — required access to an omitted or uncertain field fails to compile.

Dependency-Key Policy​

The query hooks (useRead, useList, useCount, useDistinct) build one canonical structural key from every request-affecting option and use that key as the React effect dependency. The policy guarantees the documented historical bug classes — refetch loops from inline array literals, missing requestConfig headers triggering or not triggering a request, Date vs ISO-string collisions — cannot recur.

What participates in the key​

  • Plain structural inputs — id, field, advanced, enabled, listParams, filter, sort, select, populate, include, tasks, basicOptions, advancedOptions, and the full requestConfig object (including any authorization or tenant headers) — each become a deterministic string via requestKeyFor.
  • Primitives (id, field, enabled, advanced) join the deps array directly.
  • Date values compare by instant (d:<.getTime()>), never colliding with an ISO-string filter that happens to look like the date.
  • Inline array literals like select: ['name', 'status'] are safe: writing them at the call site does NOT cause a refetch loop, even if React creates a new array identity every render. Two select arrays with the same shape produce the same key.
  • A meaningful structural change triggers exactly one replacement request, aborting the previous in-flight request via the hooks' owner-id / signal.aborted policy.

What is NOT a key input​

  • Callback identity (onSuccess, onError, onSettled) is not part of the effect dependencies. The hooks wrap each callback in a stable invoker (the standard React "useEvent" pattern) so the latest underlying callback fires at settlement time without making callback identity churn trigger a network request. Re-rendering a parent with a fresh arrow expression every render is safe.
  • initialData and keepPreviousData participate only as primitive boolean / data shape values, not as structural request inputs.

Unsupported values​

If requestKeyFor encounters a value it cannot represent deterministically, it throws a documented RequestKeyError. Query hooks catch that, rethrow a plain Error with the original RequestKeyError in cause, and interrupt render before the auto-effect runs with an unsound key. The categories are:

  • bigint — silently losing precision is unsafe; convert to a number or string before passing to a query hook.
  • function — callback identity is unstable by design; the request contract requires structural data.
  • symbol (including symbol-keyed object properties) — JSON.stringify silently drops symbols, which would collide with an object that has no such key.
  • Cycles (direct, indirect, or array) — recursion is caught via a WeakSet stack and rejected explicitly.
  • Accessor properties (getters/setters) — a getter would fire during dep-key construction. requestKeyFor checks Object.getOwnPropertyDescriptor and rejects before any getter runs.
  • Built-in instances (RegExp, Map, Set, URL, Error, and class instances whose prototype is not Object.prototype or null) — pass their plain-data representation (a URL string, a sorted array of entries, an {} literal) to the query hook instead.

Date and Object.create(null) plain objects are supported.

Request-key work is intentionally bounded. One requestKeyFor(...) call accepts at most 64 nested array/object levels, 20,000 first-visit nodes, and 200,000 serialized key characters; inputs beyond those limits throw RequestKeyError instead of overflowing the stack, partially truncating the key, or silently colliding. Keep request-key inputs as plain, stable, reasonably small wire data. Repeated object references may be reused within a single requestKeyFor(...) call, but there is no global cache retaining caller objects across renders.

Importing the helper​

Downstream consumers that want to inspect or build keys themselves can import requestKeyFor and RequestKeyError directly from @web-ts-toolkit/access-router-react:

import { requestKeyFor, RequestKeyError } from '@web-ts-toolkit/access-router-react';

const key = requestKeyFor({ filter: { status: 'active', since: new Date('2026-01-01') } });

declare const someUserSuppliedFilter: unknown;

try {
requestKeyFor(someUserSuppliedFilter);
} catch (e) {
if (e instanceof RequestKeyError) {
// handle an unsupported value before passing it to a query hook
}
}

Active Record Integration​

Data returned from useList and useRead is backed by Model<T> wrappers from @web-ts-toolkit/access-router-client.

That means you can edit loaded models directly and persist with save():

const { data, refetch } = useList({ listParams: { pageSize: 20 } });

async function rename(id: string, name: string) {
const organization = data.find((entry) => entry._id === id);
if (!organization) return;

organization.name = name;
const result = await organization.save();

if (result.success) {
refetch();
}
}

Use explicit mutation hooks when you want local pending and error state around a specific workflow.

Notes​

  • These hooks do not implement shared caching, deduplication, invalidation, retry, or background revalidation. They are thin stateful wrappers over ModelService from @web-ts-toolkit/access-router-client. If you need cache orchestration, use these services underneath a query library.
  • requestConfig is forwarded to the underlying client request via a fresh shallow copy on every request; the caller's requestConfig object, its headers, and other fields are not mutated. requestConfig.signal is composed with the caller-supplied query() options.signal and the hook-owned controller signal, and that one effective signal is used for both transport cancellation and hook settlement classification. There is no way to bypass the hook's abort manager. Replacing only requestConfig.signal does not trigger an automatic refetch, but the latest signal is used for future query executions.