Migrating from 4.x to 5.0
useStructureRestApi is rebuilt directly on TanStack Query as the single source of truth for records, lists, searches, freshness, invalidation and loading — see useStructureRestApi for how the new engine works. Most of the rest of this release is naming and typing cleanup that fell out of that rebuild. Every item below has its own entry in the CHANGELOG's # 5.0.0 section, with the full reasoning; this page is the shape of the work, not the why.
Peer dependencies
- npm install @guebbit/vue-toolkit @tanstack/query-core
+ npm install @guebbit/vue-toolkit @tanstack/vue-queryvue-query is query-core plus the Vue layer — installing query-core directly alongside it is a real failure (a client built from the wrong copy silently never fetches), not a warning. Set up one client per app:
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
const queryClient = new QueryClient()
app.use(VueQueryPlugin, { queryClient })There is no private, toolkit-owned QueryClient any more. Every useStructureRestApi/ useStructureSearchApi/useStructureCrudApi call either takes one explicitly via queryClient, or finds it through this injection — which also works inside a Pinia setup store, on pinia ^2.1 (see below). 4.x's private client set retry: false and networkMode: 'always'; a plain new QueryClient() keeps TanStack's own defaults instead. To keep the 4.x behaviour:
new QueryClient({
defaultOptions: {
queries: { retry: false, networkMode: 'always' },
mutations: { networkMode: 'always' }
}
})Peer floors moved too — see Peer dependencies for the full table:
| Peer | 4.x | 5.0 | Why |
|---|---|---|---|
@tanstack/vue-query | (not a peer) | ^5.103 | replaces @tanstack/query-core |
vue | >=3.0.0 | ^3.4 | Vue 3.3's watcher scheduler could refetch the old page at a new pageSize; 3.4 orders them correctly. On 3.0–3.3, upgrade Vue first |
pinia | >=2.0.0 | ^2.1 || ^3 || ^4 | a Pinia SETUP store built OUTSIDE a component (a router guard, main.ts) does not run inside Vue's injection context before 2.1, so useQueryClient() inside one THROWS — built inside a component's setup(), any Pinia version works. Pass queryClient explicitly to work on any Pinia version, or with no Pinia store at all |
zod (optional) | >=4.4.3 | ^4.4.3 | caret, not open-ended |
Node itself: engines.node is >=22 (4.x declared none). Node 20 reached end-of-life on 2026-04-30; Yarn 1 and engine-strict installs refuse an older Node.
Renames
| Old | New | Where |
|---|---|---|
loadingKey | resourceKey (now required) | resource option, and the returned field |
TTL | staleTime | resource option and per-call setting |
lastUpdateKey: 'x' | key: ['x'] (now an array) | per-call setting |
mismatch | partial | per-call setting |
fetchAgain | applyResponse | updateTarget setting |
IToastType | EToastType | useNotificationsStore |
IStructureRestApi | IStructureRestApiOptions | the options type |
IStructureCrudSettings | IStructureCrudApiOptions | the options type |
| — | IStructureRestApi, IStructureSearchApi, IStructureDataManagementApi | these now name each composable's return type (previously unnamed/inferred), matching IStructureCrudApi |
checkAny('x') | checkAny(['x']) | follows the key array change above |
Removed
| Removed | Use instead |
|---|---|
destroy() | stop the owning effect scope (ends subscriptions/watchers); resetAll() removes the data |
startLoading() / stopLoading() | nothing — TanStack already knows what's in flight |
getLoading / setLoading resource options | tag a call with key, read isLoading(key) |
per-call loading / loadingKey settings | key, read via isLoading(key) |
saveRecords | addRecords / editRecords |
fetchLike | createTarget (or updateTarget with applyResponse: false to opt out) |
searchCleanup() / resetSearches() / searchCached / ISearchCache | nothing — a search's pages are cache entries, read directly; there is no separate index |
searchKeyGen (on the returned object) | pass the filters object straight to searchGet/checkSearch |
Behaviour changes worth checking
These aren't renames — the name is the same, but what happens under it changed:
resourceKeyhas no random fallback. A resource that didn't set one used to get a random key; it's a required option now.- Per-call settings are typed per method. Each method accepts only the fields it actually reads — passing an unrelated one is now a type error, not a silently-ignored extra property.
- Records are read-only.
itemDictionary/getRecord(...)/parentHasManyare computed views over the cache — writing into them is ignored (with a Vue warning) instead of mutating the store. Write throughaddRecord/editRecord/updateTarget, link children withaddToParent. fetchMultipleresolves one slot per requested id (fetched first, then cached,undefinedfor one the server left out) — 4.x resolved every item the server returned, in whatever order.- Raw cache keys carry ids as strings. A direct
queryClientcall addressing a record or a parent list needsString(id), not the raw value —5and'5'now address the same entry. - A search's
apiCallresolves{ items, totalItems }, not a bare array — same forfetchSearch,search(),searchNow()andresetFilters().totalItemsnow survives a cache hit, since it's stored alongside the page that produced it. - The applied search, not the live filters, drives what's on screen. Binding a filter form's inputs straight to
filtersno longer blanks the list while the user types — nothing shows untilsearch()/searchNow()is called. See The applied search. - Changing
pageSizeon the search/CRUD layer now also resetspageCurrentto1, in the same fetch. Unchanged onuseStructureDataManagement's/useStructureRestApi's own client-side pagination. - Every watcher returns
{ stop, refetch, suspense, error }(IWatchHandle), not a bare stop function or an ad-hoc shape — see the before/after below. A watcher never rejects any more; a failure shows inerror(andonErrorwhere offered). useStructureCrudApi'soperationsparameter is required — it no longer defaults to{}.useStructureCrudApi's read operations take nooptionsparameter:list: () => …,search: (filters, page, pageSize) => …,get: (id) => …— it was never forwarded to them.updateTarget'sapiCallis now typed() => Promise<F>(was() => Promise<F | (T | undefined)[]>); a response that isn't a record object (empty, an array, a primitive) keeps the optimistic patch.updateTarget/deleteTarget's mutation key carries the id asString(id), matching a record's query key. Only visible to a hand-writtenisMutating/isLoadingfilter matching on the mutation key's id segment directly — matchString(id)instead ofid.- A resource's data outlives the effect scope that built it. 4.x cleared everything when the owning component/store was disposed; now stopping a scope only ends subscriptions and watchers. Call
resetAll()first, or rely on adependsOnchange, when data must actually leave memory. K's default type isTIdOf<T>(the type ofT['id']whenThas one), notkeyof T(the union of field names). Code that relied on the old default —getRecord('id')with the literal field name, say — needsKpassed explicitly now:useStructureRestApi<IUser, keyof IUser>(...).useCoreStore'ssetLoading(key, value)andgetLoading(key)require every argument.setLoading('x')used to quietly storefalse.useStructureCrudApi'screateOne/updateOne/deleteOnetake one settings object, not a bare per-call argument — see the before/after below.- Watcher callbacks run on every fetch that lands, not once per id or page change:
onSuccess/onError/onSettledalso fire for background refetches (after an invalidation — every create/update/delete refetches an active list or search — on window focus or reconnect once stale, or when anything else fetches the same key). Make them safe to repeat; never reset a form being edited fromonSuccess. loadingcovers the wholeresourceKey: every instance sharing it, and the background refetches of active watchers. Tag the calls a spinner should follow withkeyand readisLoading(key).resourceKeymust be unique per record type. Every resource shares the app's oneQueryClient, so two resources under one key share cache entries (user1and product1collide); a combined spinner readsuseIsLoading(['users', 'products']).resource.queryClientis that shared client:clear()on it wipes every resource — useresetAll()for one.maxRecordsdefaults to10_000(4.x:100_000), and crossing it drops unobserved lists and searches too. Set it explicitly above 10 000 records per scope.- Records are not Pinia state. Inside a setup store,
itemDictionary/parentHasManyare computeds (Pinia getters):$state,$patch,$subscribe, SSR state serialization and persistence plugins no longer carry records. Persist or transfer them through theQueryClient(persistQueryClient,dehydrate/hydrate). fetchSearchmoves the screen to the page it fetched (pageCurrent/pageSize);pageSizedefaults to the current one. Don't use it to prefetch a page the screen shouldn't show yet.watchSearch({ immediate: false })waits for the firstsearch(): page or page-size changes before it fetch nothing.applyServerErrorskeeps form-level and unmapped messages informLevelErrors(whichisValidincludes) and returnstrueonce anything was shown,onUnmappedincluded. RenderformLevelErrors, or passonUnmapped.- Child ids are typed
K, the id's own type, notstring: dropas nevercasts, and readparentHasManyasK[]. - Every read
apiCallreceives a{ signal }context as its last argument. A function passed by name whose own optional parameter sits in that slot gets the context instead of its default: wrap it,fetchAny(() => api.stats()).
Before / after
Peer swap and client setup:
// 4.x
import { createApp } from 'vue'
const app = createApp(App)
// no shared client — each resource owned its own
// 5.0
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
const queryClient = new QueryClient()
const app = createApp(App)
app.use(createPinia())
app.use(VueQueryPlugin, { queryClient })resourceKey required, TTL renamed:
// 4.x
const users = useStructureRestApi<IUser, number>({ TTL: 60_000 })
// 5.0
const users = useStructureRestApi<IUser, number>({
resourceKey: 'users', // required — no random fallback any more
staleTime: 60_000
})Every watcher returns a handle:
// 4.x
const stop = watchTarget(userId, fetchUser)
// 5.0 — apiCall first, idSource second (matches fetchTarget/watchByParent); returns a handle
const { stop, refetch, error } = watchTarget(fetchUser, userId)CRUD's createOne/updateOne/deleteOne take a settings object:
// 4.x
crud.createOne(data, axiosConfig)
crud.updateOne(id, data, axiosConfig)
// 5.0
crud.createOne(data, { requestOptions: axiosConfig })
crud.updateOne(id, data, { requestOptions: axiosConfig, merge: true })Every change above has its own CHANGELOG entry under # 5.0.0, with the exact reasoning and migration for that one item.
See also
- Getting Started — the 5.x setup from scratch.
- Changelog — every release since, with its own migration notes.