Skip to content

useStructureRestApi ​

A REST resource on one TanStack Query QueryClient. Every record, list, page and search you fetch is a TanStack query; this composable adds normalization (one record, one cache entry), a reactive read-only view over those entries, optimistic mutations with rollback, and scoping by whose data it is (dependsOn). TanStack owns caching, request de-duplication, staleness, invalidation and loading.

You pass your own already-parameterized fetch closure (() => Promise<...> around axios, fetch, a generated client, anything). No HTTP client is assumed.

Quickstart ​

App setup (QueryClient + VueQueryPlugin) is on Getting Started. Then, inside a component's setup() or a Pinia setup store:

ts
import { useStructureRestApi } from '@guebbit/vue-toolkit'
import axios from 'axios'

interface IUser {
    id: number
    name: string
    email: string
}

// resourceKey is required: the first segment of every query and mutation this resource makes
const users = useStructureRestApi<IUser, number>({ resourceKey: 'users' })

// cached for `staleTime` (default 1 hour), de-duplicated across callers
await users.fetchAll(() => axios.get<IUser[]>('/api/users').then((r) => r.data))

users.itemList.value // IUser[]
users.getRecord(1) // IUser | undefined
users.loading.value // true while anything of this resource is in flight

// optimistic: the record changes now, and goes back if the request fails
await users.updateTarget(
    () => axios.put<IUser>('/api/users/1', { name: 'New name' }).then((r) => r.data),
    { name: 'New name' },
    1
)

Opening user 1's detail view afterwards (fetchTarget(apiCall, 1)) is a cache hit: fetchAll and updateTarget both write the record's own cache entry.

Another store invalidates this resource through the shared client:

ts
queryClient.invalidateQueries({ queryKey: ['users'] })

One cache ​

Every entry of a resource lives under a key that starts with resourceKey, then says what the entry holds (its kind), then which dependsOn value it was fetched under:

KindKeyHoldsFilled by
target[resourceKey, 'target', dependsOn, String(id)]one recordfetchTarget, watchTarget, every list fetch, createTarget/updateTarget, addRecord/editRecord
all[resourceKey, 'all', dependsOn, ...key]idsfetchAll, watchAll
parent[resourceKey, 'parent', dependsOn, String(parentId), ...key]idsfetchByParent, watchByParent, addToParent/removeFromParent
page[resourceKey, 'page', dependsOn, pageSize, page, ...key]idsfetchPaginate
search[resourceKey, 'search', dependsOn, filters, pageSize, page, ...key]ids + totalItemsuseStructureSearchApi
any[resourceKey, 'any', dependsOn, ...key]whatever apiCall resolvesfetchAny with a key, watchAny
idle[resourceKey, 'idle', dependsOn]nothing: never fetchedthe placeholder watchTarget/watchByParent sit on with no id
  • A list holds ids, never records: a record fetched by a list and by fetchTarget is one entry.
  • Record and parent ids are keyed as strings: 5 and '5' (a route param) address the same entry, and relation ids compare as strings too. A raw queryClient call that addresses a record uses the string id: queryClient.invalidateQueries({ queryKey: ['users', 'target', [], '1'] }) ([] is the default dependsOn).
  • itemDictionary is a computed view of the target entries under the current dependsOn, so it follows anything that changes the cache, queryClient.clear() from elsewhere included.
  • Calls with no stable identity (fetchAny without key, fetchTarget without an id, fetchMultiple's batch) still run as queries, under a random any key that is dropped once they settle, so isLoading sees them.
  • Two instances with the same resourceKey on the same client read and write the same entries when their dependsOn agrees; with different dependsOn values, they read and write disjoint entries and coexist rather than one dropping the other's cache. Each keeps its own selection and client-side pagination either way: see dependsOn.
  • Build a resource inside an effect scope — a component's setup(), a Pinia setup store, or your own effectScope(). Its cache subscriptions are torn down through that scope; with none active there is nothing to stop them, and they leak for the QueryClient's whole lifetime. A console.warn naming the resourceKey fires once if this happens.

Setup options ​

useStructureRestApi<T, K, P>(options), options: IStructureRestApiOptions, returns IStructureRestApi<T, K, P> — both exported, explicit interfaces (not inferred), so nothing this package keeps internal ever has to appear in your own .d.ts output to describe them.

OptionDefaultPurpose
resourceKeyrequiredFirst segment of every query and mutation key: what invalidateQueries({ queryKey: [resourceKey] }) and useIsLoading address.
identifiers'id'The record field that identifies a record, or several fields (order matters) joined by delimiter.
delimiter'|'Joins the values of multiple identifiers into one id.
staleTime3_600_000 (1 hour)How long (ms) fetched data counts as fresh. A per-call staleTime overrides it.
dependsOn() => []The values this resource's data depends on (() => [session.userId, locale.value]). See dependsOn.
maxRecords10_000Critical-mass backstop on cached records. 0 disables it. See maxRecords.
queryClientuseQueryClient()The client this resource lives on. The default needs an injection context (component setup(), or a Pinia setup store in an app with VueQueryPlugin); pass the client explicitly anywhere else.
queryOptionsnoneTanStack useQuery options (retry, retryDelay, refetchInterval, refetchOnWindowFocus, refetchOnReconnect), applied to every watch* call this resource makes. A watcher's own queryOptions setting overrides this per call. See TanStack option passthrough.

T is the record type, K its id type, P a parent's id type (for fetchByParent and the relations). K defaults to the type of T['id'] when T has one (string | number otherwise); pass it explicitly for a composite id, or one under a different field name (see identifiers below).

Reading ​

One-shot reads ​

MethodSettings it readsResolves
fetchAll(apiCall, settings?)forced, merge, partial, staleTime, keythe list's items
fetchByParent(apiCall, parentId, settings?)forced, merge, partial, staleTime, keythe parent's children
fetchPaginate(apiCall, page = 1, pageSize = 10, settings?)forced, merge, partial, staleTime, keythe page's items (a plain server page, no filters)
fetchTarget(apiCall, id?, settings?)forced, merge, staleTimethe stored record
fetchMultiple(apiCall, ids = [], settings?)forced, merge, staleTimethe requested ids' records: fetched ones first, then cached
fetchAny(apiCall, settings?)forced, staleTime, keywhatever apiCall resolves

What they share:

  • Every apiCall receives a { signal } context as its last argument (IFetchContext). signal is a lazy getter: reading it is what asks TanStack to abort the underlying fetch once nothing needs it any more (an unmounted watcher, a mutation cancelling this same record's read). Forward it to fetch/axios for a request that is genuinely cancelled, not just an answer that's later discarded; ignoring it costs nothing. Every one-shot apiCall above takes only the context — fetchByParent's and fetchTarget's included: close over the parent id or record id yourself. The exceptions put their own argument first: fetchMultiple's apiCall(missingIds, context), and the watchers watchTarget (apiCall(id, context)) and watchByParent (apiCall(parentId, context)), which pass the id of the query that is running.
  • A cached entry that is still fresh is served without calling apiCall. Concurrent calls for the same entry join one request.
  • A failure rejects. An entry that already held data keeps it (stale data still renders); an entry that failed before ever holding data is removed.
  • A read the toolkit cancels itself (a dependsOn change, or an update or delete of that same record) resolves with what is cached instead of rejecting. Its answer, if it still arrives, is not stored.
  • A late answer belongs to the scope it was fetched for, not whatever dependsOn reads by the time it lands. Two instances of the same resourceKey can share a client under different scopes (see dependsOn); if instance A moves its dependsOn on while its own fetch for the old scope is still in flight, that fetch is not cancelled while instance B still shows the old scope — and its answer, once it lands, is stored under that OLD scope, exactly as if nothing had changed. Only once NOTHING shows a scope any more does a late answer for it go unstored.
  • A read never overwrites a record a newer mutation has touched. Every write a fetch*/ watch* call makes — a list read's items included — is dropped once an updateTarget/ deleteTarget on that same id, in the same scope, has started since the read began, or is still running. This holds even though a mutation only cancels its own record's in-flight read: a fetchAll or search page in flight when an unrelated record is mutated is never cancelled, and still stores every id it carries except the one being mutated. The check itself is asked of TanStack's own MutationCache — the exact question isSaving asks — so it holds across every instance of a resource sharing a client, not just within the instance that started the mutation, and '1'/1 name the same record either way.

Per method:

  • fetchTarget with an id reads through that record's entry. Without an id there is nothing to look up, so it always asks the server, and stores the answer under the record's own id. An undefined or null answer is no record: nothing is stored, and with an id the entry is cached as "nothing" until it goes stale. itemList never holds null.
    • Fetching by an alternate key (fetchTarget(apiCall, 'my-slug') resolving { id: 7 }) stores the record once, under 7 — its own id — never twice. The 'my-slug' entry becomes an alias — a pointer, { aliasOf: '7' }, holding no record of its own — and EVERY operation follows it, not just reads: getRecord('my-slug'), selectedRecord and everything that reads through getRecord follow it to 7's record, one hop; so do editRecord('my-slug', ...), updateTarget(..., 'my-slug') and deleteTarget(..., 'my-slug') — they reach the record itself, never a second, divergent copy under the alias. itemDictionary/itemList only ever see the record once, under 7. Removing, invalidating or updating record 7 by ANY of its known aliases reaches all the others too: deleting 7 (by its own id or by 'my-slug') drops the 'my-slug' pointer along with it, so a later fetchTarget(apiCall, 'my-slug') asks the server again instead of serving a stale "nothing" from the leftover pointer. updateOne(id)/ deleteOne(id) on useStructureCrudApi follow the same rule: the API still gets whatever id you pass (a slug, say), and the cache resolves it to the real record. Known limit: an address never fetched cannot be resolved before the server answers it once — the very first updateTarget(..., 'my-slug') with no prior fetchTarget/watchTarget by that slug has no pointer to follow yet, so its optimistic patch lands under 'my-slug' itself; the update's own response then resolves it into a proper pointer to the real record (see the update-response rule, below).
  • fetchMultiple asks the server only when some of ids are missing or stale, with one call of apiCall(missingIds, context) — missingIds is exactly checkMultiple(ids).expiredIds (every id, with forced), so building GET /users?ids=1,2 from it never re-asks for an id already fresh. Resolves one slot per requested id, [...expired, ...cached] in that order, with undefined for an id the server did not return.
  • fetchAny is for answers that are not records. With key, a normal cached read under [resourceKey, 'any', dependsOn, ...key]. Without, it always asks the server and caches nothing.

Active reads (watch*) ​

Each watch* is a TanStack useQuery in its own effect scope: it fetches now, and again whenever its key changes (an id, a parent id, dependsOn) or its entry is invalidated. It stops with the component or store that created it, or with stop().

MethodArgumentsReturns
watchTarget(apiCall, idSource, settings?)apiCall: (id, context) => Promise<T | undefined>. idSource: a value, a Ref or a getter of K | undefined | null. settings: IWatchTargetSettings<T, K>: forced, merge, staleTime, queryOptions, onSuccess, onError, onSettledIWatchHandle<T | undefined>
watchAll(apiCall, settings?)settings: IWatchListSettings<T, string[] | undefined>: forced, merge, partial, staleTime, key, enabled, queryOptions, onSuccess, onError, onSettled — key/enabled may be reactiveIWatchHandle<(T | undefined)[]>
watchByParent(apiCall, parentId, settings?)apiCall: (parentId, context) => Promise<(T | undefined)[]>. parentId: a value, a Ref or a getter (re-runs when it changes; nullish idles, and refetch() then resolves [] without calling apiCall). settings: IWatchListSettings<T, P>: as watchAll'sIWatchHandle<(T | undefined)[]>
watchAny(apiCall, settings)settings: IWatchAnySettings: { key, forced?, staleTime?, enabled?, queryOptions? }. key is required: an active query needs a stable identity. key/enabled may be reactive. No callbacksIWatchHandle<F | undefined>

Each fetch sends what its own query was built from: watchTarget's apiCall receives the id, and watchByParent's the parent id, of the query that is running, never a live value that has moved on since.

Every watcher returns the same handle, IWatchHandle<R>:

FieldMeaning
dataComputedRef<R>: the watched data as cached now — the record, the list's items, fetchAny's answer. Follows the watched id, parent or key.
isFetchingReadonly<Ref<boolean>>: true while the watched query fetches — its first load or a background refetch.
isPendingReadonly<Ref<boolean>>: true while the watched query has no data yet — before its first answer, and while a watcher with nothing to watch (no id, no parent) idles.
dataUpdatedAtReadonly<Ref<number>>: when the watched query last received data (ms epoch); 0 before it ever has.
stop()Ends the watcher. Its entry stays cached (see cache lifetime).
refetch()Fetches now. Joins a fetch already running instead of restarting it. Resolves with the watched data as cached after the fetch: a failure leaves the previous data in place (and shows in error). Never rejects.
suspense()For SSR: resolves once the watched data is available — cached and fresh already, or after the fetch that gets it there. Call it in onServerPrefetch, before the component renders, so the client hydrates with data already in the cache instead of fetching again on mount. A watcher that is not currently enabled (no id yet, enabled: false) resolves right away with whatever is cached, instead of waiting for enabled to turn true. Never rejects.
errorReadonly<Ref<unknown>>: the last fetch's failure, null after a success.

A watcher never rejects, so a failure shows only in error, and in onError on the watchers that take callbacks (every one but watchAny, plus watchSearch on the search layer). Those three callbacks are IWatchCallbacks<R, C> — onSuccess(result, context), onError(error, context), onSettled(result, error, context) — where R is what the watcher resolves and C is what it was watching: the id for watchTarget, the bucket key for watchAll, the parent id for watchByParent, the applied filters for watchSearch. They run a microtask after the fetch that triggered them settles, never synchronously inside TanStack's own notification of it, so a callback that throws surfaces as an ordinary uncaught error and never corrupts the query it was reporting on.

ts
const userId = ref<number | null>(null)

const { error, refetch } = users.watchTarget(
    (id) => axios.get<IUser>(`/api/users/${id}`).then((r) => r.data),
    userId,
    { onError: (failure) => console.error(failure) }
)
// users.selectedRecord follows userId

watchTarget specifics:

  • It selects: every non-nullish id becomes selectedIdentifier right away, so a cached record renders at once. A failed fetch clears the selection only when nothing is cached for that id either: a failed background refetch of a record already on screen leaves it selected, showing the stale data instead of blanking the screen over a transient error.
  • A nullish id leaves the selection as it is and fetches nothing; refetch() then resolves undefined without calling apiCall.
  • onSuccess(record, id), onError(error, id), onSettled(record, error, id) fire when a fetch of the watched record lands (it succeeds or fails), and once when the id switches to a record already cached and fresh with no fetch running (none runs then). That includes the first id.
  • A fetch that never lands fires nothing: one cancelled by an update or delete, one still paused offline, one for an id the watcher has already left. A switch during a fetch fires once, not twice.

forced: true on a watcher means every mount and every key switch asks the server.

SSR ​

Every watcher's suspense() wraps vue-query's own suspense(), resolving once the watched data is cached — fetching first if it is not there yet or is stale. Call it from onServerPrefetch, so the fetch happens during server rendering instead of again on the client after hydration:

ts
import { onServerPrefetch } from 'vue'

const userId = ref<number | null>(1)
const { suspense, error } = users.watchTarget(
    (id) => axios.get<IUser>(`/api/users/${id}`).then((r) => r.data),
    userId
)

// Awaited by the server render; the client then hydrates from the already-populated cache.
onServerPrefetch(() => suspense())

suspense() never rejects, matching refetch() — a failed prefetch still resolves (with whatever is cached, undefined on a genuinely empty miss), and shows in error for the server render to check if it needs to handle that case explicitly. A watcher that is not currently enabled (watchTarget with a nullish id, watchByParent with a nullish parent, enabled: false) resolves right away with whatever is cached instead of fetching — vue-query's own suspense() on a disabled query never resolves at all, since it only starts once enabled later turns true.

TanStack option passthrough ​

queryOptions is a narrow, explicit pick of TanStack's own useQuery options — retry, retryDelay, refetchInterval, refetchOnWindowFocus, refetchOnReconnect. Set it on the resource as a default for every watch* call it makes, or on a specific watcher's own settings to override that default for just that call:

ts
const users = useStructureRestApi<IUser, number>({
    resourceKey: 'users',
    queryOptions: { refetchOnWindowFocus: false } // applies to every watcher below
})

users.watchTarget(fetchUser, userId) // no refetch on window focus (the resource default)
users.watchAll(fetchUsers, {
    queryOptions: { refetchOnWindowFocus: true } // this one watcher opts back in
})

queryFn/queryKey/gcTime are never part of this: they encode the cache layout every fetch/watch method relies on, and letting a caller override them would break it. Only the five keys above are read — anything else on the object (a JS caller's, or a widened object's) is dropped, never passed on to useQuery. queryOptions only reaches ACTIVE queries (watch*) — a one-shot fetch* read has no observer for refetchInterval/refetchOnWindowFocus/refetchOnReconnect to mean anything on, and its own forced/staleTime settings already cover retry-adjacent freshness concerns.

Pre-flight checks ​

"Would this call be served from cache?", answered from the cache alone, without calling any API. True when the entry is cached and fresh within staleTime (the call's, or the resource's). An invalidated entry counts as stale. There is no forced variant: a forced call always fetches.

MethodAsks about
checkTarget(id, { staleTime? })fetchTarget(apiCall, id)
checkAll({ key?, staleTime? })fetchAll
checkByParent(parentId, { key?, staleTime? })fetchByParent
checkPaginate(page = 1, pageSize = 10, { key?, staleTime? })fetchPaginate
checkAny(key?, { staleTime? })fetchAny with that key; always false without one
checkMultiple(ids = [], { staleTime? })fetchMultiple: returns { cachedIds, expiredIds }

Writing ​

MethodSettingsResolves
createTarget(apiCall, dummyData?, settings?)keythe stored record
updateTarget<F = T>(apiCall, itemData, id?, settings?)merge, key, applyResponseapiCall's result (apiCall: () => Promise<F>)
deleteTarget(apiCall, id, settings?)keyapiCall's result
mutateAny(apiCall, settings?)keyapiCall's result

Each runs as a TanStack mutation keyed [resourceKey, 'create' | 'update' | 'delete' | 'any', id?] (id as String(id), the same form a record's query key uses), so loading, isLoading and useIsLoading see it — isSaving(id) reads that same id segment for update/delete.

  • createTarget: dummyData, if given, renders at once under a temporary id and is removed when the call settles, whatever it resolved. On success the returned record is stored as freshly fetched (an empty answer stores nothing), and this resource's lists are marked stale.

  • updateTarget and deleteTarget are optimistic, built directly on TanStack's own mutation lifecycle (onMutate/onSuccess/onError/onSettled), and share one protocol:

    1. TanStack marks the mutation pending — visible to isSaving, and to every OTHER read or mutation asking "is this id being changed?" — before anything below runs.
    2. onMutate cancels the record's own in-flight read (a fetchTarget or watchTarget of that id) — its answer is never stored, so it cannot undo the edit or bring a deleted record back. Nothing else is cancelled: a list read of the scope keeps running (see the write guarantee above). It then snapshots the record and applies the change locally: updateTarget merges itemData into it, deleteTarget removes it. Both are skipped once no instance shows the dependsOn value the call started under any more (see dependsOn). The snapshot is taken right here, not when the call started — a same-tick sibling mutation on the same id may already have applied its own change by this point, and a later rollback returns to THAT, never to a value from before either call ran.
    3. The request runs. Once it settles, onSuccess/onError only touch the record if it still holds exactly this call's own change: a newer mutation on the same id owns it otherwise, so neither a failed older update nor a stale older success can undo what that newer one did.
      • Success, still the owner: the response is stored (see below). Nothing is invalidated: the stored value is the server's own answer.
      • Success, a newer mutation owns the record: the response is dropped, and the record is invalidated — what is left is that newer call's local guess, so an active watcher reconciles it with the server.
      • Failure, still the owner: the record goes back to its snapshot (removed, if it did not exist), and is invalidated, for the same reason.
      • Failure, a newer mutation owns the record: nothing happens to it — the newer call's outcome stands.
    4. In the same onSuccess/onError step, this resource's lists are marked stale, success or failure alike, whoever owns the record. onSettled only detaches the mutation's observer, so the mutation leaves TanStack's MutationCache on its own gcTime.

    A read blocked by an in-flight mutation on the same id cancels its OWN query instead of resolving with its answer — TanStack then discards whatever it would have written and reverts to what is already cached, with that entry's ORIGINAL timestamp: a background read landing mid-mutation neither resurrects a record the mutation just removed, nor stamps an unconfirmed optimistic patch fresh.

  • updateTarget on success stores the response as the record's new, full data (merge: true merges it in instead). A response that is not a record object (undefined, null, an array, a primitive), or applyResponse: false, keeps the optimistic patch as the record. The optimistic patch, without an explicit id, is applied under the id inside itemData. The response is stored under the id it was asked with (id, or itemData's own) whenever the response itself carries no usable id of its own — an ack-only or partial (merge: true) response — so it always lands on the record actually being updated, never a fabricated one. When the response DOES carry its own id and it differs from the one asked with (a slug endpoint that echoes back the full record, say), the response is stored under its own id and the requested id becomes a pointer to it — the same rule a fetchTarget by that same slug follows (see the alternate-key note under Reading, above).

  • mutateAny: a command with no record shape. Invalidates nothing; call invalidateTarget/invalidateLists (below) or queryClient.invalidateQueries yourself when it changes data.

  • Marking the lists stale reaches every list kind (all, parent, page, search): active list watchers refetch now, the others on their next read.

  • If dependsOn changes while a mutation runs, its result is not stored, its rollback is skipped and no list is marked stale — unless another instance still shows the old value, in which case all three happen under that OLD value, never the new one. Either way the old scope's data never lands in the new one.

Invalidating by hand ​

For data that changed behind the resource's back (a mutateAny command, a server push, another tab), mark it stale instead of reaching for raw queryClient keys:

MethodMarks stale
invalidateTarget(id)Record id — or the record an alias points at — and every alias of it, under the current dependsOn.
invalidateLists()Every list of the current dependsOn: fetchAll, fetchByParent, fetchPaginate and search pages.

Both return a Promise<void> that settles once the watched entries have refetched; an unwatched entry just waits, stale, for its next read. Neither removes anything: what is cached keeps rendering until the fresh answer lands.

ts
await users.mutateAny(() => axios.post('/api/users/1/recompute-stats'), { key: ['stats'] })
void users.invalidateTarget(1) // a watchTarget(…, 1) refetches the user

Loading ​

MemberTypeMeaning
loadingComputedRef<boolean>True while anything of this resource is in flight. Same as isLoading().
isLoading(key?)(key?: string[]) => booleanTrue while a query or mutation of this resource runs whose key starts with key. A plain function: call it inside a computed.
isSaving(id)(id: K) => booleanTrue while an updateTarget/deleteTarget mutation on record id is running — a per-row pending signal, distinct from loading/isLoading, which cover the whole resource. A plain function: call it inside a computed.

isLoading matches by segment prefix: isLoading(['dash']) is true while a call made with key: ['dash', 'w1'] runs. With no argument it covers everything of the resource: every query and mutation on the client whose first key segment is resourceKey.

A call's key comes from its settings. fetchTarget, fetchMultiple and watchTarget take no key (a record has one cache entry, not a bucket per caller), so isLoading(key) never matches them, by design: only loading / isLoading() see them.

ts
const saving = computed(() => users.isLoading(['profile-form']))

users.updateTarget(save, patch, id, { key: ['profile-form'] })

isSaving(id) resolves id first, so isSaving('my-slug') and isSaving(7) agree once 'my-slug' is a known pointer to 7 — an updateTarget/deleteTarget call made by either resolves to the same mutation key. isSaving(id) covers updateTarget/deleteTarget only — a record being created has no stable id of its own yet to key this by, and mutateAny carries no record id at all. Like isLoading, call it inside a computed or straight in a template — both track it:

vue
<tr v-for="user in itemList" :key="user.id">
    <td>{{ user.name }}</td>
    <td><Spinner v-if="users.isSaving(user.id)" /></td>
</tr>

For "is any of these resources busy" across the app, see useIsLoading.

Records and relations ​

Everything useStructureDataManagement returns is on the resource too, backed by the cache (its identifier under the name identifierKey, listed further down):

MemberBehaviour here
itemDictionary, itemListComputedRef<Record<K, T>>, ComputedRef<T[]>: a read-only view of the records under the current dependsOn. Change records through the methods, never in place.
getRecord(...idParts), getRecords(ids)Read the view.
addRecord(item), addRecords, editRecord(data, id?, create?), editRecordsLocal writes: stored, but a record's freshness does not move. A new record starts stale, an invalidated one stays invalidated, so the next fetchTarget asks the server.
setRecords(items)Replaces every record of the current scope; none counts as fetched. Marks the scope's lists stale without refetching them.
deleteRecord(id)Removes one record.
resetRecords()Removes every record of the current scope, and marks its lists stale: active list watchers refetch (and bring their records back).
selectedIdentifier, selectedRecordSelection. watchTarget sets it; fetchTarget does not.
lastInsertedIdentifier(s), lastInsertedRecordLast-inserted tracking. Moves on createTarget's success and on a local addRecord/editRecord create. A background write that stores what the server reported (fetchTarget, fetchAll, watch*, an updateTarget response) never moves it, even when the record was not cached before — it is new to the cache, not newly created. Exception: a partial: true list fetch that brings in a record not cached yet does move it.
pageCurrent, pageSize, pageTotal, pageOffset, pageItemListClient-side pagination over itemList. The search layer redefines pageItemList/pageTotal.
parentHasManyComputedRef<Record<P, K[]>> (read-only): each parent's child ids, the union of all its parent buckets (see below), keyed by the parent id as a string.
addToParent(parentId, childId)Adds the child to the parent's keyless entry, unless the parent already lists it.
removeFromParent(parentId, childId), removeDuplicateChildren(parentId)Edit every bucket of that parent.
getRecordsByParent(parentId?), getListByParent(parentId?)The records of parentHasMany[parentId] (by id / as a list, in the relation's order); ids with no cached record are skipped. No parentId: none.

A parent's buckets are its keyless parent entry plus every fetchByParent(..., { key }) entry for it. Its children are the union of their ids, in first-seen order, without duplicates. The relation editors write those entries directly and mark them stale, so the next fetchByParent asks the server.

Plus the resource's identity and plumbing:

MemberMeaning
resourceKey, maxRecordsThe options, as resolved.
identifierKeyThe identifier field name(s), joined by delimiter.
createIdentifier(item, identifiers?)The id of a record (fills a missing identifier with a random fallback).
queryClientThe client, markRaw'd so it survives a Pinia setup store's reactive() wrapper.
resetAll()Drops every entry of this resource under the current dependsOn, then refetches what active watchers show.

Settings reference ​

IFetchSettings, plus IUpdateTargetSettings for updateTarget and IWatchListSettings/ IWatchAnySettings (key/enabled reactive) for watchAll/watchByParent/watchAny. Each method reads only the fields listed in its table above; its TypeScript signature accepts only those.

SettingDefaultMeaning
forcedfalseRun with staleTime: 0: anything cached counts as stale, so the server is asked. A concurrent call for the same entry joins this request instead of racing it. On a watcher: every mount and key switch asks the server.
mergefalseMerge the fetched fields into the stored record instead of replacing it.
staleTimethe resource'sFreshness window (ms) for this call.
keynoneExtra segments appended to the cache key of a list, fetchAny or watchAny call: an independent bucket for the same call shape. Also what isLoading(key) matches, on queries and mutations alike. On watchAll/watchByParent/watchAny, may be a Ref, ComputedRef or getter: a change re-runs the watcher under the new bucket.
partialfalseThe answer holds partial records: merge them (never replace) and keep each record's freshness, so the next full fetchTarget still asks the server. List-shaped fetches only.
applyResponsetrueupdateTarget only: store the response as the record (a response that is not a record object is never stored). Turn off when the response is not the record (an acknowledgement, say).
enabledtruewatchAll/watchByParent/watchAny only: whether the watcher may fetch on its own. May be a Ref, ComputedRef or getter. watchByParent also idles automatically while its parentId is nullish, regardless of this setting.

dependsOn ​

dependsOn is a getter returning whose data this is: () => [session.userId, locale.value]. Every call reads it when it starts and files its entries under that value. It has to read reactive state (a ref, a store field) for a change to be noticed.

  • Under the old value, everything is cancelled and dropped (records included), not merely marked stale: one user's data never shows for the next, and a language switch never mixes languages — unless another live instance still shows that value (see below), in which case it is left alone.
  • An answer or rollback for the old value that arrives after the change is stored under the OLD value, same as always, as long as some instance still shows it; discarded only once nothing does.
  • Active watchers switch to the new value on their own.
  • When a resource is created, it drops every entry of its resourceKey cached under a dependsOn value nothing still alive claims: the scope changed while no instance was around to clean up after it. A value another live instance is currently showing is left alone — two instances of the same resourceKey under different dependsOn values (two shops compared side by side, a master/detail pair where only the detail's own dependsOn moves on) coexist rather than one wiping the other's cache out from under it on creation, or on a later dependsOn switch.

Cache lifetime ​

  • Records, parent lists and search pages (target, parent, search) are never garbage-collected: they stay cached while nothing watches them, because stale data is what renders while fresh data downloads, and getListByParent/pageItemList read them straight from the cache. Records leave only through dependsOn (a change, or the sweep when a resource is created), resetRecords/resetAll, deleteRecord/deleteTarget, maxRecords, or your own queryClient calls; parent lists and search pages through dependsOn, resetAll, maxRecords or your own calls. getListByParent therefore keeps working after fetchByParent, and pageItemList after fetchSearch, with no watcher.
  • Everything else (all, page, any) keeps your client's default gcTime: TanStack's is 5 minutes in a browser (unlimited on a server), unless your QueryClient sets another. Nothing renders those entries directly, so an unwatched one leaving only means the next call asks the server again; a watch* observes its entry and keeps it alive while it runs. (The records a list fetched stay either way, so itemList is unaffected.)
  • Stopping a scope (unmount, store disposal, stop()) ends subscriptions and watchers. It never removes cache entries.
  • TanStack's own hydrate() (what persistQueryClient uses to restore a cache persisted on a previous visit) reaches the views like any other write: getRecord, itemList and the rest see a restored record right away, with nothing else needing to happen first.
  • Removing an entry that an active watcher observes empties it in place instead, so the watcher stays attached. resetAll() then refetches what active watchers show (views empty, then the watched data comes back). resetRecords() refetches only active list watchers (it marks the lists stale), not a watchTarget. deleteRecord/deleteTarget refetch nothing themselves: a watcher fetches again on its next invalidation, window focus or key change. The maxRecords wipe is different again: an observed record is never even a candidate for eviction (see maxRecords) — nothing is emptied, so there is nothing to refetch.
  • A failed one-shot read (fetch*, not watch*) that never held any data is only an error marker, so it is removed once nothing else needs it — unless an active watcher shows that same key. There it is left alone, not emptied: emptying it would reset the watcher back to its initial (pending, no error) state and wipe the very error the watcher exists to display. The watcher keeps showing that error and retries it on its own (window focus, reconnect, its own staleTime); a one-shot entry that already held data before the failed refetch is never touched either way.

maxRecords ​

A critical-mass backstop, not an eviction policy: records are never evicted for being old.

  • When a list-shaped fetch (fetchAll, fetchByParent, fetchPaginate, fetchSearch, their watchers), fetchMultiple, or fetchTarget with an id / watchTarget fetching a record not cached yet is about to write (fetchTarget without an id never checks the bound), and the records cached under the current dependsOn plus the incoming new ones would exceed maxRecords, every other entry of the current scope is dropped first (records, lists, searches) — one detail page at a time crosses the bound exactly like a list does.
  • Only records not cached yet count: refetching a list, or a record, that is already cached adds nothing, so it never triggers the wipe.
  • The fetch that crosses the bound keeps its own entry: it resolves its items (or record) and caches it. Queries still fetching are spared too: their answers are on the way.
  • What is on screen is never dropped by the wipe — collected straight from the cache right before it runs: a record something is actively watching; the records behind a watched LIST's own rows, even though those individual records have no observer of their own; and the record a watched ALTERNATE-KEY pointer resolves to, same reasoning. All still count toward the bound, they are just never the ones evicted — dropping any of them would leave something on screen pointing at ids that resolve to nothing (a list), or serving "nothing" for a record still shown elsewhere (an alias), with nothing telling it to refetch. The bound can be exceeded by what is on screen — already true for a directly-watched record, and this only extends the same rule to a watched list's rows and a watched alias's target.
  • The mutations (createTarget included) and addRecord/editRecord never trigger it: each writes a single record you hold the data for, not a batch of possibly-new ones.
  • Harmless for server-paginated screens. An infinite-scroll screen rendering itemList sees the list collapse to the last batch: set maxRecords: 0 and prune yourself if that matters.

Gotchas ​

  • watchTarget selects, fetchTarget does not. A watcher is bound to a screen's id, so what it watches is the current record. fetchTarget only stores: a prefetch or a background refresh must not steal the selection. Set selectedIdentifier yourself, or use useStructureCrudApi's fetchOne, which selects.
  • Records are read-only. Write through editRecord/updateTarget, never by mutating a record you read back.
  • partial: true for a list endpoint that omits detail-only fields: it merges, and does not make the fuller record look freshly fetched.
  • Two mutations on the same record race by arrival order, not by which one you called first. The write guarantee (above) protects a mutation from a read's stale answer, not from another mutation on the same id: updateTarget's success is guarded against a newer mutation (a concurrent deleteTarget can't be resurrected by a stale updateTarget success), but a deleteTarget that then itself fails still rolls back to whatever the record held right before it applied its own change — which, raced against another mutation, may be that mutation's own optimistic (not yet server-confirmed) value, not a server-confirmed one. The record is invalidated either way, so an active watcher reconciles it, but a one-shot caller with nothing watching sees that optimistic value until it reads again. Two mutations on the same record from two different call sites is not a pattern this library linearizes; a screen editing one record has one place doing the editing.

See also ​

Documentation for @guebbit/vue-toolkit v5.2.0. Released under the AGPL-3.0 license.