react-toolroom
v1.1.1
Published
Zero-dependency React toolset: runtime memoization without useCallback, and composable data-fetching hooks without a Provider.
Maintainers
Readme
React Toolroom
A zero-dependency React toolset: runtime memoization without
useCallback, and composable data-fetching hooks without a Provider.
Highlights
- Zero dependencies, tiny footprint — the full entries are 2.7 kB (
react-toolroom) and 7.4 kB (react-toolroom/async), minified + brotli, shared chunk included; both tree-shake, so your real cost is the capabilities you import, not the full entry. - No Provider, no Context — every hook works standalone; state lives on the functions you pass in, so there is nothing to mount at the app root.
- Atomic, composable hooks — each capability is one small hook. Combine
useCache+usePollinglike building blocks, and tree-shake the rest. - Cross-component injection — any component can attach middleware (wrappers) to another component's fetcher via the onion model; wrappers are removed automatically on unmount.
- React 16.8 – 19 — one code path, broad peer range.
- TypeScript first — authored in TypeScript,
.d.tsgenerated from source; 358 tests.
Install
npm i react-toolroomTwo entries: react-toolroom (core: memo, createMemoryCacheProvider, stableHash) and react-toolroom/async (data-fetching hooks).
When to choose this library
React Toolroom does not try to be a full server-state manager. It gives you the highest-frequency 20% — caching, deduplication, polling, focus and reconnect revalidation, cancellation, mutation-linked invalidation — in ~6.4 kB with no Provider (less when you cherry-pick). This is an honest comparison:
| Capability | react-toolroom | TanStack Query | SWR | ahooks useRequest |
| --- | --- | --- | --- | --- |
| Runtime dependencies | 0 | 0 | 0 | ahooks itself |
| Global Provider required | No | Yes (QueryClientProvider) | No | No |
| Request deduplication | built-in (load on the cache provider; concurrent same-args useRun runs share one request even with no cache) | built-in | built-in | ✗ (debounce/throttle only) |
| Polling | usePolling | refetchInterval | refreshInterval | pollingInterval |
| Refetch on focus | useFocusRevalidate | refetchOnWindowFocus | revalidateOnFocus | refreshOnWindowFocus |
| Refetch on reconnect | useReconnectRevalidate | built-in | built-in | ✗ |
| Mutation lifecycle | useMutation | useMutation | useSWRMutation | manual |
| Invalidation linked to mutations | useInvalidate / invalidates | invalidateQueries | manual mutate | manual |
| Infinite loading | ✅ useInfinite | useInfiniteQuery | useSWRInfinite | useInfiniteScroll |
| Keep previous data on key change | default + usePlaceholderData flag | placeholderData: keepPreviousData | keepPreviousData: true | ✗ |
| DevTools | ✅ InjectDevTools panel (separate entry) | ✅ | community | ✗ |
| SSR / hydration | ✅ dehydrate/hydrate | ✅ | ✅ | limited |
| Fetch middleware | onion wrappers, per component, no Provider | ✗ (query cache events only) | ✅ (via SWRConfig) | ✗ |
| React versions | 16.8 – 19 | 18+ (v5) | 16.11+ (v2) | 16.8+ (v3) |
| Bundle size¹ | 2.7 kB + 7.4 kB | ≈ 13 kB | ≈ 4 kB | ≈ 5 kB+ |
¹ Minified + compressed, full entry without tree-shaking — an upper bound; cherry-picked imports are smaller. react-toolroom numbers are exact, measured from the CI build; competitor numbers are approximate and vary by version — check their docs.
Choose react-toolroom for small-to-mid applications, for embedding inside a component library, or when you only want to cherry-pick a few capabilities at minimal cost. Choose TanStack Query when you want a managed server-state client — cache-wide invalidation by query-key predicates, mutation-to-query coordination handled by the client itself, persistence plugins, and its full DevTools.
One storage boundary follows from the design and is worth knowing up front: an injectable's result store is a single broadcast slot, so useResult reads the last settle, whatever args produced it (that is what makes keep-previous-data the default and renders cheap). Screens driving one injectable with several argument sets read through the keyed channel instead — useKeyedResult(fn, args) / useArgsStatus(fn, args) — which is the per-queryKey observer semantics TanStack gives you by construction.
Write your project's query hook once
React Toolroom ships no configurable preset hook — there is no useQuery(options). That is deliberate: an options surface that re-encodes what the atomic hooks already express directly costs as much to maintain as the composition itself, and it hides the mechanism at exactly the moment you need to see it. Instead, write your project's query hook once, then use it everywhere — modifying that composition later is no more work than editing a config object.
The recipes/ directory holds copy-and-customize templates to start from:
| Template | Composition |
| --- | --- |
| useProjectQuery.ts | The base: useInjectable + useRun(…, {signal: true}) + useResult / useInitialLoading / useError. |
| useProjectMutation.ts | The write side, on top of the first-class useMutation: pins the project's default failure reporting while an explicit onError still replaces it — the pattern for adding a house convention to a library hook. |
| useProjectSWRQuery.ts | Adds useCache(staleTime) over a module-level cache instance + useFocusRevalidate, with the useError read claiming the instance's failures (every trigger lands in the returned error field, never a dangling rejection) and useRefresh as the returned stable refetch — drop the entry, force one fresh request. |
| useProjectPollingQuery.ts | Adds usePolling on a fixed interval. |
| useProjectPaginatedQuery.ts | Keyed useRun(fn, [{page}], {hash: stableHash}) with default keep-previous-data made observable by usePlaceholderData, useLoading as the small refresh indicator and useInitialLoading as the first-screen skeleton. |
| createLocalCacheProvider.ts | A CacheProvider that persists entries to localStorage — for caches that should survive a page reload instead of rebuilding from scratch. |
Copy the one closest to your screen, swap in your fetcher, and adjust the common customization points each file header calls out: staleTime, error reporting, the cache instance, and the return shape. One hook gives every screen a single loading/error contract — and exactly one place to change it.
For the server side of the story — priming caches in the App Router, hydration, and what does and does not work inside React Server Components — see the Next.js / RSC integration guide.
For the render-cost side of the story — why a settle always produces new references (no structural sharing), what that costs your React.memo boundaries, and how to keep re-renders cheap (scalar props, stable on* identities, useResultSelect slices, reference-preserving writes) — see the recipe No structural sharing: the memo strategy.
Quick start
Core: memo, the useCallback-free React.memo
import {memo} from 'react-toolroom';
const MemoSendButton = memo(SendButton);
function Chat() {
const [text, setText] = useState('');
const [messages, setMessages] = useState<string[]>([]);
// `onClick` is a brand-new function on every render, yet the memoized
// button skips re-rendering while the user types — no `useCallback`.
return (
<>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<MemoSendButton onClick={() => setMessages([...messages, text])} />
</>
);
}memo stabilizes function props that look like event handlers (/^on[A-Z]/ by default) and forwards to the latest handler on call, so the child sees a stable identity while your closures stay fresh.
Async: compose hooks around one injectable
import {
useError,
useInitialLoading,
useInjectable,
useResult,
useRun
} from 'react-toolroom/async';
import {fetchList} from './services/user';
function UserList() {
// 1. Make the fetcher injectable (a hook — call it before the hooks below).
const fetchUsers = useInjectable(fetchList);
// 2. Add capabilities in any order; each hook registers one wrapper.
useRun(fetchUsers, []); // run once on mount
const users = useResult(fetchUsers);
const initialLoading = useInitialLoading(fetchUsers);
const error = useError(fetchUsers);
if (initialLoading) return <p>loading…</p>;
if (error) return <p>{error.message}</p>;
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.username}</li>
))}
</ul>
);
}DevTools: mount a call-trace panel
import {InjectDevTools} from 'react-toolroom/devtools';
// Dev-only, anywhere in the tree — separate entry, inline styles, zero deps.
<InjectDevTools injectables={[fetchUsers]} />
// Preset hooks keep their injectable internal — name it
// (useInjectable(fn, {name: 'fetchTags'})) and drop the prop:
// the panel discovers every live named injectable on its own.
<InjectDevTools />memo and the React Compiler
The React Compiler reached 1.0 and became stable in October 2025. It memoizes automatically at build time: you add the compiler to your build, and it rewrites components so their values keep stable identities.
react-toolroom/memo is a runtime, zero-configuration solution — a drop-in replacement for React.memo that stabilizes event-handler props. They do not conflict, and memo stays useful wherever the compiler cannot help:
- Projects not on the compiler toolchain — legacy codebases, gradual migrations, builds where adding the Babel plugin is not (yet) an option.
- Components the compiler skips — the compiler bails out on patterns it cannot statically analyze; those components keep re-rendering unless memoized by hand.
- Library code published un-compiled — your app's compiler does not process
node_modules, so a component library shipped as plain JS still benefits from stabilizing the handler props it receives. - Broad React range —
memoworks on React 16.8 through 19 with one code path.
If you run the compiler, keep it: it covers derived data inside components. memo covers handler identity across component boundaries — a layer the compiler only fixes for code it actually compiles.
The injection mechanism (onion model)
useInjectable(fn) returns a function with the same signature as fn whose identity is stable across renders. Calling it runs the original function through every registered wrapper — outermost first, innermost last, closest to the original function:
flowchart LR
A["call site<br/>fetchUsers(...)"] --> B["wrapper registered later<br/>(outermost)"]
B --> C["wrapper registered earlier<br/>(innermost)"]
C --> D["original function"]
D -- result travels back out --> C
C --> B
B --> A
B -. broadcasts .-> S["shared stores<br/>result / loading / error"]
S -. updates .-> U["subscribers<br/>in every mounted component"]Every capability hook — useResult, useLoading, useCache, … — is just a useInject registration, which is why they compose in any order. Wrappers are registered once per hook instance (re-renders and StrictMode double-renders cannot duplicate them) and are removed automatically when the injecting component unmounts.
Because the wrapper list lives on the injectable itself, a component can attach behavior to a fetcher created by another component — cross-component injection with no Provider:
import {useInject, useInjectable} from 'react-toolroom/async';
import {fetchList} from './services/user';
// A custom wrapper: receives the next inner function, returns a replacement.
function withTiming() {
return (f: typeof fetchList) => async (...args) => {
const start = performance.now();
try {
return await f(...args);
} finally {
console.log(`fetchUsers took ${performance.now() - start}ms`);
}
};
}
function UserList({fetchUsers}: {fetchUsers: typeof fetchList}) {
useInject(fetchUsers, withTiming());
// …useResult / useRun / render…
}
function DevProbe({fetchUsers}: {fetchUsers: typeof fetchList}) {
// Attaches a wrapper to a fetcher it does not own.
// Removed automatically when <DevProbe /> unmounts.
useInject(fetchUsers, (f) => async (...args) => {
const users = await f(...args);
console.log('fetched', users.length, 'users');
return users;
});
return null;
}
function UsersPage() {
const fetchUsers = useInjectable(fetchList);
return (
<>
<UserList fetchUsers={fetchUsers} />
{import.meta.env.DEV && <DevProbe fetchUsers={fetchUsers} />}
</>
);
}A wrapper receives (nextFn, callContext): nextFn is the next inner layer to call, and callContext is a fresh object per call — when useRun runs with {signal: true}, the trailing AbortSignal is exposed as callContext.signal so deeper wrappers can observe cancellation. For state shared across calls, getInjectContext(fetchUsers) returns the injectable's stable context object (this is where the result and loading stores live). useInjectBefore is the advanced variant: it inserts the wrapper at the head of the chain instead of the tail, so it is applied before previously registered wrappers and ends up as the innermost layer, closest to the original function.
Registration does not require a hook. The injection module also exposes addWrapper(fn, wrapper) — the non-hook primitive with the InjectWrapper<F> signature (f, callContext) => f — which pushes onto the same chain and returns an unsubscribe function; subscribeInjectEvents is nothing more than a thin observer built on top of it, which is how non-React tooling (log panels, devtools) taps into a chain. In the other direction, useRun does not even require an injectable: it probes its argument with isInjectable(fn) up front, and plain functions skip the wrapper machinery entirely while keeping the same run-on-change behavior.
One thing a per-instance chain cannot do is discovery: an observer attached to one instance never sees calls through another (two components using the same preset each own a separate useInjectable), and a preset's injectable never leaves the hook. For tooling there is an opt-in discovery channel — useInjectable(fn, {name}) publishes the instance into a module-level registry for its component's lifetime, and <InjectDevTools /> watches every live member without needing a reference (see the recipe below).
Recipes
Deduplicate rapid clicks — the cache provider
import {createMemoryCacheProvider} from 'react-toolroom';
import {useCache, useInjectable, useResult} from 'react-toolroom/async';
// Module scope: one cache, shared by every component that imports it.
const reportCache = createMemoryCacheProvider<Report, []>({cacheTime: 60000});
function Widget() {
const loadReport = useInjectable(fetchReport);
useCache(loadReport, reportCache);
const report = useResult(loadReport);
// The API takes 2 s. Clicking 5 times while it is in flight sends ONE
// request; every concurrent caller receives the same result.
return <button type='button' onClick={() => loadReport()}>Refresh</button>;
}Deduplication is the cache provider's job, not a separate hook: useCache routes every fetch through the provider's load, which keeps one in-flight slot per key — same args while a request is pending → one promise, one request. The slot vacates on settle, so a failed call can be retried, and the same provider dedupes across channels: two components, a useRun rerun and a router loader sharing the cache share the request. On the no-cache path useRun provides the same sharing itself: concurrent runs of one injectable with the same logical args (two mounted components, or StrictMode's double effect) join the in-flight request instead of issuing a second one — TanStack Query's default dedup. An entry dies with its promise, a {signal: true} run yields its place synchronously when its signal aborts (a same-stack successor starts fresh), and plain (non-injectable) functions are exempt — they own no shared stores. A provider that does not implement load (custom localStorage/IndexedDB stubs) simply does not dedupe its own reads; useRun's sharing still applies.
Poll and revalidate on focus — usePolling + useFocusRevalidate
const statCache = createMemoryCacheProvider<FocusStat, any[]>({cacheTime: 60000});
function Dashboard() {
const loadStat = useInjectable(fetchFocusStat);
const isStale = useCache(loadStat, statCache, 5000); // fresh for 5 s
useFocusRevalidate(loadStat); // refetch on window focus / tab re-visible
useRun(loadStat, []);
const stat = useResult(loadStat);
// Switch away for > 5 s, come back: cached data renders instantly,
// a background revalidation follows.
}
// Polling lives in a child so mounting/unmounting starts/stops the timer
// (hooks cannot be called conditionally).
function Ticker() {
const loadTicker = useInjectable(fetchTicker);
useRun(loadTicker, []);
usePolling(loadTicker, 3000); // every 3 s
const ticker = useResult(loadTicker);
}usePolling skips a tick while the previous call is still pending (a slow API never piles up concurrent requests) and pauses while the document is hidden unless you pass {whenHidden: true} (environments without a document — workers, React Native — have no hidden state and always poll). Every tick's settle outcome is recorded on the error channels — useError and useArgsStatus(fn, args) — even when no error channel is mounted at tick time, so a failure that happened while nobody watched stays readable for a channel mounting later; the next successful tick clears it. useFocusRevalidate throttles with {interval} (default 0), and both revalidation hooks gate by entry age when you pass {cacheProvider, staleTime} — TanStack's refetchOnWindowFocus under a staleTime: a refocus/reconnect while the entry is younger than staleTime skips the refetch; without a provider every event revalidates. Their rejections never surface as unhandled: they flow through the error channels and are otherwise swallowed (fire-and-forget). Both also take {args}: when the fetcher is keyed (useRun(loadUser, [userId])), pass the same tuple — usePolling(loadUser, 10000, {args: [userId]}) — so every tick resolves to the same useCache key instead of opening a second request line keyed by [].
Cancel stale requests — useRun with a signal
const loadDetail = useInjectable((detailId: number, signal: AbortSignal) =>
fetchDetail(detailId, signal)
);
// Each run appends a fresh AbortSignal as the last argument; the previous
// signal is aborted when `id` changes or the component unmounts.
useRun(loadDetail, [id], {signal: true});
const loading = useLoading(loadDetail);
const detail = useResult(loadDetail);
const error = useError(loadDetail); // aborted calls reject with AbortErrorCombine with useCatch to keep showing the previous data instead of an error when a request is superseded. useRun also accepts plain (non-injectable) functions.
Cache with stale-while-revalidate — useCache
// Module scope: createMemoryCacheProvider is not a hook, so the cache can
// be shared by every component that imports it.
const userCache = createMemoryCacheProvider<User[], any[]>({cacheTime: 10000});
function UserList() {
const fetchUsers = useInjectable(fetchList);
const isStale = useCache(fetchUsers, userCache, 2000); // staleTime: 2 s
const users = useResult(fetchUsers);
useRun(fetchUsers, []);
return isStale ? <UserListSkeleton users={users} /> : <UserTable users={users} />;
}On a cache hit, the cached value is broadcast to every subscriber immediately — components render data without waiting for the network. If it is older than staleTime (default 0: every hit revalidates), a background refetch follows and updates everyone when it lands; its failures are swallowed so stale data stays on screen. With defaults, createMemoryCacheProvider() hashes keys with stableHash and reclaims an entry after 5 minutes of idleness (cacheTime: 300000, TanStack Query's gcTime default) — loader-primed entries nobody consumes no longer reside in memory forever; pass cacheTime: Infinity to keep entries for good. Reclamation is per entry (like TanStack Query's gcTime): every read or write refreshes the entry's clock, an entry idle for the full cacheTime is deleted — with an in-flight request never collected — and an entry observed by a mounted useCache consumer is exempt until the consumer unmounts (TanStack Query's "a query with observers is never collected"). Two channels drive the sweep: the last unmounted useCache consumer schedules a final scan, and every write debounce-schedules one, so entries written by channels nobody mounts (a router loader priming the cache) are reclaimed too. One trade-off to know: every access reschedules the (single) sweep deadline, so an entry read periodically — say by polling — postpones reclamation indefinitely, for itself and for any other unobserved entries waiting on that scan. The returned stale flag is keyed state: each args tuple carries its own staleness verdict in the injectable's keyed store, and the hook reports the verdict of the tuple the displayed result was fetched with — when one injectable serves several args tuples, one tuple going stale never flips another tuple's display (an invalidate of one key no longer flags a screen showing another). With a single args tuple, every consumer of the injectable still reads one shared verdict and updates together.
Because every fetch useCache starts (a miss or a stale background revalidation) goes through the provider's load, concurrent consumers share one in-flight request — including consumers on different injectables, as long as they read the same provider with the same args. StrictMode double-mounts, two components fetching the same key in one commit, a router loader running the same read: one network request, one write-back.
Read-through with in-flight sharing — load / peek
createMemoryCacheProvider() entries are three-state: settled data, an in-flight request, or both (stale data served while a background revalidation runs). Two provider methods expose that machinery directly — this is the seam a router loader uses to share a cache with useCache:
const userCache = createMemoryCacheProvider<User, [string]>({cacheTime: 60000});
// Router loader channel: read-through with in-flight dedup.
const user = await userCache.load([id], () => api.user(id));
// Same args while that request is pending — from this loader, a useCache
// consumer, anything — share the very same promise; the factory runs once.
// Synchronous peek: settled data or undefined. Never observes an
// in-flight request, never starts one — safe for render-time checks.
const cached = userCache.peek([id]); // {value, cachedAt} | undefinedload(args, factory)— atomic get-or-insert of the in-flight slot: an existing pending request for the same key is returned as-is (factorynot invoked), otherwisefactory()runs once and is registered. When it settles, the provider writes the result back itself. A per-key generation counter, bumped by every write (set/delete/deletePrefix/deleteWhere, wiped byclear), guards the write-back: if anything wrote to the key while the request was in flight — a mutation's write-through, an invalidation — the late response is dropped instead of clobbering the newer value.cachedAtis stamped from settlement, not from when the request started, so a slow response does not eat into the data'scacheTime/staleTimebudget. A rejection vacates the slot, keeps any previously settled data (SWR: a failed background refetch leaves the stale value on screen) and rethrows to the caller as-is.peek(args)— reads the settled entry ({value, cachedAt}orundefined) without observing in-flight requests and without ever creating one: checking the cache can never trigger a fetch. (getalso reads only settled data, but as the tuple[value, cachedAt];peekis the object-shaped, request-free contract non-React channels program against.)
In-flight state is deliberately invisible to get/peek/dehydrate/snapshot data rows — only settled data is ever "the cache". snapshot() marks entries that additionally have a request running with an additive pending: true.
Invalidate after a mutation — useInvalidate
const userCache = createMemoryCacheProvider<User[], any[]>({cacheTime: 10000});
function UserList() {
const fetchUsers = useInjectable(fetchList);
useCache(fetchUsers, userCache, 5000);
const invalidateUsers = useInvalidate(fetchUsers, userCache);
const users = useResult(fetchUsers);
useRun(fetchUsers, []);
async function handleRename(user: User, name: string) {
await renameUser(user.id, name); // the mutation
await invalidateUsers(); // then refresh the list
}
// …render `users`, wire `handleRename` to your edit form…
}Unlike a stale-while-revalidate background refetch — which keeps serving the old value while refreshing — useInvalidate is a hard invalidation: it deletes the cache entry under the given args, then immediately re-runs the injectable with them, so subscribers see a fresh loading → result cycle instead of the pre-mutation data. The key linkage mirrors useCache: the same cacheProvider plus the same args tuple address the same entry (useInvalidate(fetchUser, userCache)(userId) drops what useRun(fetchUser, [userId]) populated). The returned function is referentially stable and resolves to the fresh result, so await it in the mutation handler before closing your toast.
Refresh the current query — useRefresh
const userCache = createMemoryCacheProvider<User, [string]>({cacheTime: 60000});
function UserProfile({userId}: {userId: string}) {
const fetchUser = useInjectable(getUser);
useCache(fetchUser, userCache, 5000);
const refresh = useRefresh(fetchUser, [userId], userCache);
const user = useResult(fetchUser);
useRun(fetchUser, [userId], {signal: true});
return (
<>
{/* …render `user`… */}
<button type='button' onClick={() => refresh()}>Refresh</button>
</>
);
}useRefresh is the "refresh this query" button: calling the returned callback deletes the cache entry under the hook's current args, then re-runs the injectable with them — a forced fresh fetch. It bypasses everything that would fold the call back into existing work: the settled cache entry and the provider's in-flight load slot are deleted with the entry, so the refresh can never join the very request it is replacing.
The entry a useRun({signal: true}) rerun wrote is keyed by the args tuple with the trailing AbortSignal; useRefresh addresses both shapes (the plain tuple and its trailing-signal twin — stableHash collapses every signal instance to one placeholder, and a signal-stripping hash (stableHash(stripVolatile(args))) normalizes the two deletes to one), so the delete always hits without any manual argument stripping at the call site. The callback is referentially stable for the hook's lifetime and always refreshes the newest render's args; a revalidation-slot claim suppresses the double fetch its own deletion event would otherwise trigger in mounted useCache consumers; and the returned promise never rejects — failures resolve undefined and surface through useError/useArgsStatus, so onClick={() => refresh()} is safe as-is.
Where useInvalidate(fn, cache) takes its args per call and hands you the rejection-owning promise for mutation handlers, useRefresh(fn, args, cache) closes over the query's args and is safe to fire and forget — the refetch button vs. the post-mutation refresh.
Errors as state — reading claims them
function UserList() {
const fetchUsers = useInjectable(getUsers);
useCache(fetchUsers, userCache);
const users = useResult(fetchUsers);
const error = useError(fetchUsers); // ← this mount claims the errors
useRun(fetchUsers, []);
if (error) return <ErrorPanel error={error} retry={() => fetchUsers()} />;
// …render `users`…
}Mounting any error-reading hook — useError, useArgsStatus, useFailureCount — declares ownership of the injectable's failures: while a reader is mounted, every call resolves undefined on failure instead of rejecting, so fire-and-forget triggers (useRun, polling, focus/reconnect revalidation, useRefresh, a plain void fn() call) never leave a dangling unhandled rejection. Nobody reading keeps the default: rejections flow to whoever holds the promise.
The claim is a marker slot on the injectable's wrapper chain, not a wrapper at a chain position: order carries no semantics — a cached layer still sees the real rejection, so a swallowed failure is never mistaken for a settled undefined and written to the cache. The slot is registered during render (a render-time early caller is covered, exactly like a tail wrapper), confirmed in an insertion effect, and removed on unmount; discarded render passes are reclaimed at the call boundary. useMutation keeps rejections flowing to the caller regardless — its internal status reads are not a user's declaration of ownership; per-call .catch on the returned promise stays the way to branch imperatively. Components that need the rejection itself (a useSuspenseResult error boundary) simply don't mount a reader on that injectable.
Optimistic update — useOptimistic
function TodoList() {
const fetchTodos = useInjectable(fetchAllTodos);
const saveTodo = useInjectable((todo: Todo) => api.save(todo));
// Same injectable as the mutation itself: the snapshot derives from the
// current result and the call args.
useOptimistic(saveTodo, (draft, todo) => ({
...draft,
items: [...draft.items, todo] // optimistic append
}));
const todos = useResult(fetchTodos);
useRun(fetchTodos, []);
const error = useError(saveTodo); // rollbacks still surface the error
async function handleAdd(todo: Todo) {
await saveTodo(todo); // resolves to the server truth
}
// …render `todos`, wire `handleAdd` to the input…
}useOptimistic is optimistic UI: each call of the wrapped injectable immediately publishes a snapshot computed by the updater from the current result and the call args, then lets the real call run — success overwrites the snapshot with the server truth through the normal result broadcast, failure rolls the store back to the pre-call value while the rejection keeps flowing (useError still catches it). Pair it with useInvalidate for the split that matters: optimistic snapshots for edits you can predict locally (toggles, appends, renames — instant feedback, zero extra requests), hard invalidation for data you cannot compute yourself (a mutation that reshapes a list rendered elsewhere). Return a new value from the updater: returning nothing keeps the previous value, so mutating draft in place neither re-renders nor leaves anything to roll back to.
SSR: dehydrate and hydrate
const userCache = createMemoryCacheProvider<User, [string]>({cacheTime: 60000});
// Server: prime the cache during prefetch, then serialize it into the HTML.
userCache.set([id], await fetchUser(id));
const payload = userCache.dehydrate(); // plain, JSON-safe object
// e.g. `<script id="cache" type="application/json">${JSON.stringify(payload)}</script>`
// Client: restore before the first render.
userCache.hydrate(JSON.parse(document.getElementById('cache')!.textContent!));
function User({id}: {id: string}) {
const fetchUser = useInjectable((signal) => api.user(id, signal));
const isStale = useCache(fetchUser, userCache, 5000);
useRun(fetchUser, [id]);
// …
}dehydrate() flattens the internal map into a plain {[hashedKey]: [value, timestamp]} object — JSON.stringify-safe, so it can travel through HTML, props, or a hand-rolled RPC. On the client, call hydrate(payload) before the first render: the first useCache lookup is then a cache hit and paints immediately; entries older than staleTime are revalidated in the background, exactly like any normal stale hit. hydrate merges — it never clears entries the client already holds — and timestamps survive the trip, so staleness math stays correct.
Persist across reloads — persist
const tagsCache = createMemoryCacheProvider<string[], any[]>({
cacheTime: 60000,
persist: {key: 'my-app:tags'}
});persist mirrors every settled entry to localStorage under one key and refills from it on the next creation — a refresh hands back the pre-refresh cache instead of a skeleton flash. The payload is {v, data}, and its semantics are deliberately conservative:
- Version gate — a stored table whose
vdiffers frompersist.version(default1) is discarded wholesale: no migration (the disk is a rebuildable mirror, not a source of truth — bump the version when your entity schema changes) and no wipe (a rejected read must not erase what it rejected). Corrupt JSON and structurally damaged tables get the same silent fresh start. - Real age —
cachedAtsurvives the round trip, so a restarted entry is as old as it really is: it renders immediately, then revalidates in the background like any stale hit understaleTime, never masquerading as fresh. - One mirror write per cache event — set/delete/clear/GC each re-serialize the full table, after a live diff against what is stored: equal writes are skipped, which is what keeps a cross-tab ping-pong at one round.
- Cross-tab convergence — a
storageevent for this key (another tab's new mirror, or itsremoveItemlogout wipe) clears this tab's memory so consumers refetch server truth. Foreign bytes are never re-hydrated. clear()wipes — the empty table is mirrored first, then the key is removed outright, so a quota-swallowed empty write cannot leave the previous session's data behind.- Silent degradation — SSR (no
window), a throwinglocalStorage, quota exceeded, or a non-JSON-safe value never throw: the memory cache stays authoritative. Values must be JSON-safe —undefinedand functions are dropped,Dates land as strings. enabled— a predicate evaluated at the creation-time hydrate and before every mirror write. Returnfalseto suspend persistence (a mock-data mode must not dirty the mirror); suspension touches only the disk — memory updates, cross-tab clearing, and theclear()wipe keep working, and the next enabled write re-serializes the full table, missed writes included.
Prefix invalidation
// Hash convention: namespace each entity's keys with a prefix.
const cache = createMemoryCacheProvider<unknown, any[]>({
hash: (args) => 'user:' + stableHash(args)
});
// One line drops every user entry — and only user entries.
cache.deletePrefix('user:');delete(key) and useInvalidate target exactly one entry. deletePrefix(prefix) batches: it walks the hashed keys and removes every one starting with prefix. Pair it with a hash convention ('user:' + stableHash(args)) and a mutation that can affect many cached users at once — say a role change for a whole team — invalidates the entire user: namespace without touching post: or any other prefix. The removal fires the same deletion event invalidate does, so mounted useCache consumers refetch — no extra wiring.
Structural removal — deleteKey
// Every snapshot row carries the hashed key it is stored under.
const row = cache.snapshot().find((r) => r.value === target);
// Remove through that key directly — no args re-hash involved.
cache.deleteKey(row.key);delete(key) addresses an entry by re-hashing the raw args tuple. That tuple is the very array reference the setter passed, so an in-place mutation after set (a reused args buffer gaining an element) drifts the hash off the stored key and the delete misses; a hydrated (SSR) entry never had a tuple at all. deleteKey(hashedKey) closes both gaps: it removes exactly the entry stored under the hashed key — the same string a snapshot() row carries, recorded at write time, immune to any later tuple drift. It fires the same {type: 'delete'} event as delete (carrying the entry's raw tuple when recoverable), bumps the same per-key generation (an in-flight request cannot resurrect the removed entry), and no-ops on an unknown key. The DevTools panel's Remove button prefers it whenever the provider implements it, falling back to the args re-hash — and flagging a miss — on providers that predate it.
Declare what a mutation invalidates — invalidates / invalidate
useInvalidate is imperative: you call the invalidator yourself in the success handler. invalidates is the same flow declared where the mutation lives — the library runs it on success, and only on success:
// Module-level caches — invalidation targets them directly, so the editor
// component never needs a reference to any injectable.
const feedCache = createMemoryCacheProvider<Article[], any[]>({cacheTime: 60000});
const articleCache = createMemoryCacheProvider<Article, any[]>({cacheTime: 60000});
function Editor({slug}: Props) {
const [save, {isMutating}] = useMutation(saveArticle, {
invalidates: [
feedCache, // (a) the whole provider
[articleCache, slug] // (b) by prefix: entries whose args start with slug
]
});
// save() resolves → both caches are purged and every mounted useCache
// consumer of them refetches and re-broadcasts. A rejected save
// invalidates nothing.
}
// The imperative twin, for non-mutation moments (a websocket push, a
// logout, a manual refresh button):
invalidate([feedCache]);Invalidation addresses the cache provider, not any injectable: providers are the data's home (usually module constants), while injectables — hook-instance identities that are awkward to pass across the tree — own only the state broadcasts. The prefix args are type-checked element-wise against the provider's key tuple at compile time. Per target:
- Purge. A bare provider clears outright. A
[provider, ...argsPrefix]tuple removes exactly the entries whose raw args tuple structurally extends the prefix —[feedCache, 'news']leaves thesportsentries alone — via the provider'sdeleteWhere, which works under anyhashconvention because matching happens in args space. This is a pure cache operation: no injectable is touched, no request is issued, and no mounted consumer is required — which is also why an unmounted screen's entry can be purged with nothing but the provider in hand. - Revalidate — passively.
useCachesubscribes to its provider's deletion events. Whenever entries a consumer has seen are removed — byinvalidate, theinvalidatesoption,deletePrefix, a DevTools panel button, expiry, any writer — their tuples are re-run through the injectable's wrapper chain: a hard cache miss that refetches and broadcasts the fresh result to every subscriber, exactly like a focus revalidation. The sharedstaleflag rises first, so subscribers can render their refreshing indicator. One delete event, however many consumers, still means one refetch per args tuple (in-flight revalidations dedupe). With no mounted consumer nothing is re-run, but the purged cache already guarantees the next mount fetches fresh.
How this maps to TanStack Query's invalidateQueries:
| | invalidates / invalidate | TanStack invalidateQueries |
| --- | --- | --- |
| Where the link lives | on the mutation, next to its write function | in onSuccess, calling a client method |
| Addressing a query | the cache provider + its args prefix (the args are the cache key) | hierarchical queryKey arrays, predicates |
| Active queries | refetch through the provider's deletion event (same mechanism for every writer) | refetched immediately |
| Inactive entries | purged from the provider — next mount fetches fresh | marked stale — refetch on next use |
| Reach | exactly the providers you name, no global state | the whole QueryClient cache |
| Failed mutation | invalidates nothing (declared, not guarded) | onSuccess never runs — same effect, you code it |
useOptimistic composes on top: optimistic snapshots for edits you can predict locally, invalidates for the data you cannot.
Serialize rapid-fire mutations — scope
A favorite button fired twice in a row is two racing writes: the second request can settle before the first, and the article ends up showing whatever landed last — not what the user clicked. scope serializes those calls — the semantics of TanStack Query's mutationKey + scope:
function FavoriteButton({slug}: Props) {
const [toggle, {isMutating}] = useMutation(toggleFavorite, {
// The mutate arguments ARE the key: one chain per article.
scope: (id: string) => `favorite:${id}`
});
return (
<button disabled={isMutating} onClick={() => toggle(slug).catch(() => {})}>
♥
</button>
);
}Semantics:
- Same key ⇒ one FIFO chain. A queued call waits until every earlier same-scope call settles — success or failure — and nothing is dropped. Different keys run in parallel, and scope-less calls keep exactly today's behavior.
isMutatingcounts from the click. The queue sits inside the loading store, so a queued call is mutating while it waits, not only while it runs.statusrides the same clock —'pending'from the click through the settle.- The chain is module-level. Unmounting the caller never abandons queued calls — they run to completion, matching TanStack's mutation scopes.
- Failures don't break the chain. A rejected call hands the queue to the next one, while its own rejection still travels to its caller (
.catchit, or letonErrorreport it). - A scope function that throws — or resolves falsy — falls back to scope-less parallel execution: a broken keyer must not take the caller down.
The signature is scope?: string | ((...args) => string) — a literal key, a zero-arg keyer, or a function receiving the mutate arguments. It resolves at call time, so FIFO follows call order.
How it composes with the rest of the pipeline: the queue wraps the mutation lifecycle, so a queued call's onMutate — and a bound mutation's optimistic update step — run when the call's turn comes, not at click time; onSuccess / invalidates fire per call, in call order, at each call's own success. Reads are untouched: invalidates still just purges providers, and the refetches it triggers observe whatever the serial writes have landed by then.
useLoading vs useInitialLoading
const initialLoading = useInitialLoading(fetchUsers); // no data yet at all
const refreshing = useLoading(fetchUsers); // any call in flightuseLoading—truewhile any call is in flight, initial load or background refresh.useInitialLoading—trueonly while a call is in flight and no result exists yet (fresh or cached). This is SWR'sisLoadingsemantics: once any data is on screen, background refetches no longer count. Use it for the full-page skeleton; useuseLoadingfor the "refreshing…" indicator.
Per-key status — useArgsStatus
useLoading / useError are injectable-level: one in-flight count, one error slot per function. That is right for a single-args screen, but when one injectable serves several argument sets at once — a list of Row({id}) components, a form watching two keyed queries — concurrent calls overwrite each other's flag and whichever call settled last decides what every observer sees.
useArgsStatus(fn, args) keys the bookkeeping by the structural args hash instead:
function Row({id}: {id: number}) {
const {loading, error, data} = useArgsStatus(fetchUser, [id]);
// Row 1 shows its own spinner while Row 2 loads — and a Row 1 failure
// never shows on Row 2.
if (loading) return <Spinner />;
if (error) return <ErrorRow error={error} />;
return <UserCard user={data} />;
}error is typed Error | undefined by default — no as Error | undefined assertion needed where you read it. APIs that reject with richer error shapes narrow it through the E type parameter, mirroring useError's:
const {error} = useArgsStatus<typeof fetchUser, ApiError>(fetchUser, [id]);
// error: ApiError | undefined — a declaration of what the call rejects
// with, not an inference; the slot holds whatever actually landed.data is typed the same way — R<AF> | undefined, exactly what useResult would hand you — while staying under the provenance gating below.
The keyed slots ride the same wrapper chain as everything else (the hook registers the read-stack wrappers itself, useCache-style), observability never changes call semantics, and each key's slot is reclaimed once its calls drain and its outcome is superseded. Retention is bounded: a key holding a failure outcome stays observable until a same-key success clears it, and the store keeps at most 100 such keys per injectable, evicting the longest-idle drained ones first — a screen enumerating many distinct args (infinite scroll, filter churn) cannot grow the map without bound.
Besides the live slots, the status also carries the settle metadata of the scoped data — the TanStack Query dataUpdatedAt / dataUpdateCount analogues:
const {data, dataUpdatedAt, dataUpdateCount} = useArgsStatus(fetchUser, [id]);
// <footer>updated {Math.floor((Date.now() - dataUpdatedAt) / 1000)}s ago</footer>dataUpdatedAt—Date.now()of the most recent successful settle of exactly these args. It ridesdata's provenance contract: a number while the displayed result was fetched with these args,undefinedotherwise (including while a different args tuple's result is on display). Failures never touch it — a failed refetch of the same args leaves the last success stamped, so "data as of T" stays truthful across error states.dataUpdateCount— successful settles of these args since they last took the display:1on the first,+1per same-args re-success. When these args retake the display after another tuple — or a provenance-unknown emission (an optimistic snapshot,useInfinite's accumulated pages) — held it, the series restarts at1: it answers "the displayed data has updated N times since these args took the display", not a lifetime per-key tally. Use it as the effect dependency for "data changed" transitions (flash a row, replay an animation).
Read results keyed — useKeyedResult(fn, args)
The result store is injectable-level and single-valued: useResult reads the last settle, whatever args produced it. On a single-args screen that is the whole story. When one injectable serves several argument sets at once — two keyed queries on one screen, a list of Row({id}) components — whichever tuple settles last decides what every useResult reader renders. useKeyedResult is that same read gated by provenance:
function Row({id}: {id: number}) {
const {data, dataUpdatedAt, loading, error} = useKeyedResult(fetchUser, [id]);
// Row 1 never renders user 2's data while Row 2's fetch settles, a Row 1
// failure never shows on Row 2 — and vice versa.
if (error) return <ErrorRow error={error} />;
if (loading && data === undefined) return <Spinner />;
return <UserCard user={data} fetchedAt={dataUpdatedAt} />;
}data is the shared last result exactly while it was fetched with these args — undefined while a different tuple's result is on display (the single-value store has no per-key retention; keeping each key's data across displays is the cache provider's job, and useCache refetches broadcast straight back into the keyed read). dataUpdatedAt rides the same contract, and loading/error are the per-key slots of the keyed store, so sibling calls with other args never flip them.
The hook is a projection over useArgsStatus — same keyed mechanism, same subscriptions, no separate bookkeeping — keeping only the result channel: data is R<AF> | undefined, typed like bare useResult's read but gated by the key instead of reading whatever tuple settled last. Everything true of useArgsStatus is true here, including that mounting it claims the injectable's errors (see Errors as state). The observability extras (failureCount, dataUpdateCount) stay on useArgsStatus; the two hooks share one keyed store.
Suspend instead of a skeleton — useSuspenseResult
import {Suspense} from 'react';
// The owner drives the fetch — OUTSIDE the Suspense boundary.
function UserList() {
const fetchUsers = useInjectable(fetchList);
useRun(fetchUsers, []);
return (
<Suspense fallback={<p>loading…</p>}>
<UserTable fetchUsers={fetchUsers} />
</Suspense>
);
}
function UserTable({fetchUsers}: {fetchUsers: typeof fetchList}) {
const users = useSuspenseResult(fetchUsers); // suspends until data exists
// …render the table, no `undefined` branch, no skeleton…
}useSuspenseResult throws the in-flight promise instead of returning undefined, so declarative fallback UI replaces manual loading flags. It only reads — starting the fetch stays the job of useRun, polling, or a manual call. ⚠️ The driver must live in a parent outside the <Suspense> boundary: a suspended subtree never commits, so its effects never run — calling useRun and useSuspenseResult in the same component deadlocks (the call that would end the suspension never starts). A driver that never starts is a silent hang — the thrown promise settles only when a first result lands — so DEV builds emit a one-time console.warn when a suspension outlives a ~1s grace window with no result and no call in flight. Once the first result has landed, every later result flows in through the shared result store exactly like useResult.
Keep previous data while paging
function UserList() {
const [page, setPage] = useState(1);
const loadUsers = useInjectable((query: {page: number}) => fetchUsers(query));
// `hash` compares args structurally, so only a real page change re-runs.
useRun(loadUsers, [{page}], {hash: stableHash});
const users = useResult(loadUsers); // still the previous page while the new one loads
const isPlaceholderData = usePlaceholderData(loadUsers, [{page}]); // true while it does
const loading = useLoading(loadUsers); // true — show a small indicator, not a skeleton
return (
<div>
{loading && <p>loading…</p>}
<ul style={{opacity: isPlaceholderData ? 0.5 : 1}}>
{users?.map((u) => (
<li key={u.id}>{u.username}</li>
))}
</ul>
<button type='button' disabled={page === 1} onClick={() => setPage(page - 1)}>
Prev
</button>
<button type='button' onClick={() => setPage(page + 1)}>
Next
</button>
</div>
);
}When page changes, the previous page stays on screen until the new result lands: the shared result store is never reset between calls, and a per-call sequence ticket drops the result of any call older than the latest applied one — a slow request can't clobber the data a newer call already delivered. TanStack Query needs placeholderData: keepPreviousData and SWR needs keepPreviousData: true for this; here it is the default, no option required. Pair it with useInitialLoading for the very first load (full-page skeleton) and useCache to revisit a page instantly from cache while it revalidates in the background.
Since the kept data is just the old result, consumers need a way to tell whose data they are rendering. usePlaceholderData(fn, args) answers exactly that: the shared store records the args tuple the displayed result was fetched with, and the flag compares it (structurally, via stableHash, ignoring an appended AbortSignal) against the current args — true while the previous page is on screen, false once the new one lands. With no result at all yet, an optional third argument plays the TanStack placeholderData role: pass the same value to useResult(fn, placeholderData) and it is displayed (and flagged) until the first result ever arrives. Results of unknown provenance — optimistic snapshots, useInfinite's accumulated pages — are never claimed as placeholders.
Subscribe to a slice — useResultSelect(fn, select)
A list endpoint returns {articles, articlesCount}, but the pagination bar only needs the count. useResultSelect subscribes the component to the projected slice only, like TanStack Query's select:
const count = useResultSelect(fetchArticles, (r) => r.articlesCount);
// with an initial value: useResultSelect(fetchArticles, (r) => r.articlesCount, initialData)The projection is memoized on the identity of the result and of select itself: until a new result (or a new selector — e.g. one rebuilt from state via useCallback) arrives, getSnapshot returns the cached output. A select that builds a fresh object per call therefore never trips useSyncExternalStore's unstable-snapshot loop detection, unrelated re-renders never re-run it, and memoized children receiving the slice stay skipped. select is not called while no result exists — the hook returns undefined until then, exactly useResult(fn)'s contract projected.
It's a separate hook rather than a useResult option, so useResult users never bundle its code — like every hook here, it tree-shakes on its own.
Infinite loading — useInfinite
function ProjectFeed() {
// The fetcher takes a single pageParam and returns one page.
const fetchPages = useInjectable((cursor?: number) => api.projects(cursor));
const {pages, fetchNextPage, isFetchingNextPage, hasNextPage} = useInfinite(
fetchPages,
{getNextPageParam: (lastPage) => lastPage.nextCursor}
);
useRun(fetchPages, [undefined]); // first page, like any other query
return (
<>
{pages.flatMap((page) => page.items).map((p) => (
<Card key={p.id} item={p} />
))}
<button
type='button'
disabled={!hasNextPage || isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage ? 'Loading…' : hasNextPage ? 'Load more' : 'End'}
</button>
</>
);
}The hook aggregates the fetched pages into an array and publishes that array to the injectable's result store; read pages from its return value instead of useResult (the store holds the whole array, not a single page). The returned shape is a subset of TanStack Query's useInfiniteQuery — {pages, pageParams, fetchNextPage, fetchPreviousPage, isFetchingNextPage, isFetchingPreviousPage, hasNextPage, hasPreviousPage} with the same meanings — minus everything that presupposes a query-client. pageParams is parallel to pages (index i holds the param that fetched pages[i]).
Bidirectional paging: pass an optional getPreviousPageParam(firstPage, allPages, firstPageParam, allPageParams) and fetchPreviousPage() prepends to the front of pages; without it hasPreviousPage stays false and fetchPreviousPage() is a no-op. An optional maxPages (default Infinity) caps the window: a forward fetch past the cap sheds the oldest pages, a backward fetch the newest, trimming pages and pageParams in lockstep — and since the boundary flags are derived per render from the current pages, a trimmed end becomes fetchable again as soon as a param can be derived there.
Only calls issued through fetchNextPage()/fetchPreviousPage() grow the list; anything else (a useRun rerun, a manual call, a focus revalidation) resets pages/pageParams to that single result, so a refetch naturally restarts the list — the default resetOn: 'rerun'. Pass resetOn: 'args' when a rerun means "refresh", not "restart": the aggregation then survives any non-directional call whose args match a page it already shows (an invalidation re-running the first page — or every page — a focus revalidation, a same-args useRun rerun), and only a call with args outside the list restarts it (a filter change driving useRun(fetchPages, [newParam])). The rerun's fresh page deliberately does not enter the aggregation — pair the mode with useCache so other consumers get invalidation-driven freshness while the paged list stays put; a trailing AbortSignal is ignored in the comparison, so a {signal: true} rerun of the same page counts as the same args. getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) returning undefined marks the end (hasNextPage turns false). fetchNextPage/fetchPreviousPage are in-flight debounced per direction — TanStack's default behavior: while a fetch of one direction is still in flight (a double click before the first settles), later calls of the same direction no-op with undefined instead of re-deriving the same param and appending/prepending the page twice; the two directions stay independent.
Observe every call — subscribeInjectEvents
const fetchUsers = useInjectable(fetchList);
// A zero-dependency call trace — no DevTools panel required.
const stop = subscribeInjectEvents(fetchUsers, {
onCall: (args) => console.log('→ fetchUsers', ...args),
onSettle: ({args, result, error, duration}) =>
console.log('← fetchUsers', {args, result, error, duration})
});
// stop() removes the observer again.subscribeInjectEvents is a plain function, not a hook — register from an effect, a module, or the browser console. The observer is registered as the outermost wrapper, so onSettle fires exactly once per call with {args, result | error, duration} where duration measures the entire onion chain it observes (original function plus every wrapper registered before the subscription). A minimal log panel is just state on top: push each settle event into an array and render it. For a runnable tour of the same onion model — cross-component injection, layer order, automatic removal on unmount — see the demo at demos/views/Async/Inject.tsx.
Observe injectables inside presets — useInjectable(fn, {name})
The wrapper chain lives on the hook instance: two components using the same preset (useQuery-like compositions) each own a separate useInjectable, and an observer attached to one instance never sees the other's calls. Correct for behavior — each component composes its own chain — but it leaves a panel blind, because the preset's injectable never leaves the hook and there is no reference to hand to <InjectDevTools injectables={…}>.
{name} opts into the module-level named registry, the discovery channel for exactly that case:
import {useInjectable, useRun, useResult} from 'react-toolroom/async';
import {InjectDevTools} from 'react-toolroom/devtools';
// A preset: the injectable stays internal — name it for the registry.
function useTags() {
const fetchTags = useInjectable(fetchTagList, {name: 'fetchTags'});
useRun(fetchTags, []);
return useResult(fetchTags);
}
function Tags() {
const tags = useTags();
return <ul>{tags?.map((t) => <li key={t}>{t}</li>)}</ul>;
}
function App() {
return (
<>
<Tags />
{/* No references handed in: watches every live named injectable. */}
{import.meta.env.DEV && <InjectDevTools />}
</>
);
}Semantics:
- Registration follows the component's lifetime. The instance is published on mount and unregistered on unmount (registration happens in an effect, never during render, so no discarded-render orphans; StrictMode's simulated unmount/remount re-registers cleanly).
- Duplicate names coexist. Two components using the same named preset are both registered — each unmount removes exactly its own instance, and a panel observes calls through every live instance sharing the name.
- The name becomes the display name. The returned function's
nameproperty is set to it, so log rows, Refetch matching and stack traces readfetchTagsinstead of'anonymous'. - The option is static per call site. The first render's value is fixed — toggling the name later would reorder the component's hooks.
- Without a name, nothing changes. No registration, no effect, no state: the unnamed path is exactly what it was before the registry existed.
- Name async injectables. The panel observes through
subscribeInjectEvents, which awaits each call's result — a named synchronous function would crash its own calls while a panel is watching.
The registry lists instances; it does not touch behavior. Every per-instance store (wrapper list, call context) stays where it was — <InjectDevTools> simply enumerates the live members instead of needing a reference. Panels that pass injectables explicitly keep watching exactly what they were handed; pass registry: true to watch both.
API reference
Core — react-toolroom
| API | Description |
| --- | --- |
| memo(Component, options?) | React.memo that auto-memoizes event-handler props, removing the need for useCallback. options: {testEvent?, propsAreEqual?} or a bare propsAreEqual(prev, next) function. |
| memoBase(Component, {testEvent, propsAreEqual?}) | Lower-level variant that requires the full options object (no defaults filled in). |
| defaultTestEvent(key) | The default testEvent: /^on[A-Z]/.test(key). |
| stableHash(value) | Structural hash: sorted object keys, Map/Set aware, circular-reference safe, AbortSignal → fixed placeholder, symbols fold by registry key (sym#…) or description (sym:…) — anonymous symbols and same-description pairs collide by design, object keys holding undefined are dropped ({a: 1, b: undefined} hashes like {a: 1}, so schema outputs that omit defaulted fields and state objects that carry them as undefined properties land on one key). Exported from both entries; the default hash of createMemoryCacheProvider, and a building block for your own keys, e.g. hash: (args) => 'user:' + stableHash(args). |
| createMemoryCacheProvider({cacheTime?, hash?, persist?}) | The in-memory CacheProvider: entries are three-state (settled data, in-flight request, or both), load is the atomic get-or-insert of the in-flight slot that deduplicates concurrent same-key reads, and per-entry GC reclaims entries idle for cacheTime. persist mirrors every settled entry to localStorage — {v, data} version gate, cachedAt preserved (real-age SWR), event-driven mirror writes with a pre-write diff, cross-tab storage clearing, clear() wiping the key, enabled suspension. Framework-free — import it from the core entry for router loaders and non-React code. |
| isAbortSignal(value) | true for AbortSignals: an instanceof fast path plus a duck-typing fallback (aborted property + addEventListener function), so the check survives cross-realm signals (iframes, test doubles) and environments without a global AbortSignal. Exported from both entries; the basis of stableHash's signal placeholder and useRun's signal bridge. |
| stripVolatile(value) | Recursive normalization for multi-channel keys: strips AbortSignals at any depth (top level, array slots, object values — cross-realm aware via isAbortSignal) and object keys holding undefined, so stableHash(stripVolatile(args)) lands on ONE key for every channel that assembles the same entity's args differently — a loader handing the provider a schema output (defaulted fields absent, no signal) vs a view handing its state object (defaulted fields as undefined properties) plus the trailing signal a useRun rerun attached. Arrays keep their remaining slots in order; Map/Set pass through untouched; not cycle-safe. Exported from both entries. |
| hashArgs(args) | One-step cache-key derivation for an args tuple — the composition stableHash(stripVolatile(args)) as a first-class export, so multi-channel call sites don't hand-write it. Signals stripped at every depth and undefined-valued keys folded before hashing: loader-side schema outputs and view-side state objects (plus a trailing AbortSignal) land on ONE key. Same shape as the hash option — pass it straight to createMemoryCacheProvider({hash: hashArgs}) or useRun(fn, args, {hash: hashArgs}) for tuples carrying volatile slots. Exported from both entries. |
Async — react-toolroom/async
| API | Description | | --- | --- | | `useInjectable
