useStructureCrudApi
A whole resource (list, filtered search, read, create, update, delete) declared from the API calls that reach it. The highest-level entry point in the toolkit, and the one to start from.
It is useStructureSearchApi with the wiring done. That wiring is the same for every resource of an app, so writing it per resource means stores that differ only in which endpoint each one calls.
Quickstart
import { defineStore } from 'pinia'
import { useStructureCrudApi } from '@guebbit/vue-toolkit'
interface IProductFilters {
text?: string
minPrice?: number
}
export const useProductsStore = defineStore('products', () => {
const api = useStructureCrudApi<IProduct, string, IProductFilters>(
{
list: () => listProducts().then((r) => r.data.items),
search: (filters, page, pageSize) =>
listProducts({ ...filters, page, pageSize }).then((r) => ({
items: r.data.items,
totalItems: r.data.total
})),
get: (id) => getProductById(id).then((r) => r.data),
create: (data) => createProduct(data).then((r) => r.data),
update: (id, data) => updateProductById(id, data).then((r) => r.data),
remove: (id) => deleteProductById(id)
},
{ resourceKey: 'products', initialFilters: { text: '' } }
)
return { ...api }
})That store is complete. A list screen reads its state through storeToRefs (destructuring the store directly would lose reactivity) and its methods from the store itself:
import { storeToRefs } from 'pinia'
const store = useProductsStore()
const { filters, pageItemList, pageCurrent, pageTotal, totalItems } = storeToRefs(store)
const { watchList, searchNow, resetFilters, deleteOne } = store
const { error } = watchList()<input v-model="filters.text" @keyup.enter="searchNow()" />
<button @click="searchNow()">Search</button>
<button @click="resetFilters()">Reset</button>
<ProductRow v-for="item in pageItemList" :key="item.id" :item="item" />
<span>{{ totalItems }} products</span>
<button :disabled="pageCurrent <= 1" @click="pageCurrent--">Previous</button>
<span>{{ pageCurrent }} / {{ pageTotal }}</span>
<button :disabled="pageCurrent >= pageTotal" @click="pageCurrent++">Next</button>For the whole screen, store to template, see Your first CRUD screen.
A detail screen:
const store = useProductsStore()
const { selectedRecord } = storeToRefs(store)
const { error } = store.watchOne(() => route.params.id as string)watchList() and watchOne() called in a component's setup() stop when that component unmounts, even though the resource lives in the store.
Operations
Each operation is a plain function returning a promise: you unwrap your HTTP client's envelope inside it. That is the whole integration surface; no client, interceptor or response shape is assumed.
| Operation | Signature | Powers |
|---|---|---|
list | (context: IFetchContext) => Promise<(T | undefined)[]> | fetchList |
search | (filters: F, page: number, pageSize: number, context: IFetchContext) => Promise<ISearchResult<T>> | watchList, searchNow, resetFilters, fetchPage |
get | (id: K, context: IFetchContext) => Promise<T | undefined> | fetchOne, watchOne |
create | (data: C, options?: O) => Promise<T | undefined> | createOne |
update | (id: K, data: U, options?: O) => Promise<T | undefined> | updateOne |
remove | (id: K, options?: O) => Promise<unknown> | deleteOne |
optimisticPatch | (data: U) => Partial<T> | the patch updateOne applies locally (default: data itself) |
ISearchResult<T> is { items: (T | undefined)[], totalItems: number }; the table above is IStructureCrudOperations<T, K, F, C, U, O>, useStructureCrudApi's first argument.
Every operation is optional, but the operations object is required. A read-only resource supplies list and get and nothing else. A method whose operation is missing returns a rejected promise, Error('useStructureCrudApi - no "search" operation was supplied'); on watchList and watchOne that failure shows in error and onError.
createOne/updateOne/deleteOne each take one settings object, its requestOptions field forwarded untouched to the operation as its own last argument — which is how per-call client config (onUploadProgress, a client's own cancellation token) reaches a request: createOne(data, { requestOptions: { onUploadProgress } }). createOne also exposes createTarget's dummyData (a placeholder rendered under a temporary id while the request runs) and updateOne exposes updateTarget's merge/applyResponse; all three take key, matched by isLoading(key). list, search and get take no options, but do receive IFetchContext — a { signal } (see structure-rest-api) — as their last argument, which is the read-side equivalent for cancellation: forward context.signal to fetch/axios so an abandoned read is genuinely cancelled.
optimisticPatch
updateOne applies a patch to the local record before the server answers, and rolls it back if the request fails. Usually the payload is that patch. Override it when the two differ, e.g. a multipart form whose payload carries a File the record should never hold:
optimisticPatch: ({ imageUpload, ...fields }) => fieldsFilters and when a search runs
filtersis aRef<F>holding a detached copy ofinitialFilters. A form may edit it in place: it never shares objects withinitialFilters, soresetFilters()really returns to them.- Editing
filtersfetches nothing and changes nothing on screen. What is shown follows the applied search; a page change fetches another page of it, without the edits. searchNow()applies the current filters from page 1: the Search button.resetFilters()restoresinitialFilters(default{}) and searches again from page 1, forced: a reset asks the server, not the cache that produced the state being reset.watchList()'s handle has its ownsearch(), which applies the filters on the current page (seewatchSearch).
As-you-type search is the screen's choice (watchDebounced from @vueuse/core):
watchDebounced(filters, () => searchNow(), { debounce: 300, deep: true })API
useStructureCrudApi<T, K, F, C, U, O, P>(operations, settings), both required.
Type parameters: T the record, K its id, F the filters, C the create payload (default Partial<T>), U the update payload (default Partial<T>), O the per-call options of create/update/remove, P a parent's id.
settings: IStructureCrudApiOptions<F> is everything useStructureRestApi accepts (resourceKey required, identifiers, staleTime, dependsOn, maxRecords, queryClient, ...) plus:
| Option | Type | Default | Purpose |
|---|---|---|---|
initialFilters | F | {} | Starting value of filters, and what resetFilters() returns to. |
Returns everything useStructureSearchApi returns (type IStructureCrudApi<...>, an exported, explicit interface — not inferred), plus:
| Member | Settings | Behaviour |
|---|---|---|
filters | Ref<F>: the live filters. Read when a search is applied, never watched. | |
fetchList(settings?) | IFetchSettings (all fields) | fetchAll over list. Resolves the records. |
fetchPage(page = 1, pageSize = 10, settings?) | IFetchSettings (all fields) | fetchPaginate over search({}, page, pageSize): one unfiltered page. Leaves the applied search alone and discards totalItems. |
watchList(settings?) | IWatchSearchSettings<T, F> | watchSearch over search. Returns IWatchSearchHandle<T> (the watcher handle plus search()); never rejects. |
searchNow(settings?) | IFetchSettings (all fields) | pageCurrent = 1, then fetchSearch of the current filters. Resolves { items, totalItems }; rejects on failure. |
resetFilters(settings?) | IFetchSettings (all fields) | filters back to a fresh copy of initialFilters, then searchNow({ forced: true, ...settings }). |
fetchOne(id, settings?) | Pick<IFetchSettings, 'forced' | 'merge' | 'staleTime'> | Selects id, then fetchTarget over get. On failure it rejects, and clears the selection if the selection is still id. |
watchOne(idSource, settings?) | IWatchTargetSettings<T, K>: forced, merge, staleTime, queryOptions, onSuccess/onError/onSettled | watchTarget over get: selects the id (a value, a Ref or a getter), keeps it fetched. Returns IWatchHandle<T | undefined> (the watcher handle). |
createOne(data, settings?) | ICreateOneSettings<T, O>: requestOptions, dummyData, key | createTarget over create. Resolves the stored record. |
updateOne(id, data, settings?) | IUpdateOneSettings<O>: requestOptions, merge, applyResponse, key | updateTarget over update, patching locally with optimisticPatch(data). The operation's answer is stored as the record; an answer that is not a record object (undefined, null) keeps the patch. |
deleteOne(id, settings?) | IDeleteOneSettings<O>: requestOptions, key | deleteTarget over remove: removed locally first, restored on failure. |
Gotchas
fetchOneselects;fetchTargetdoes not.fetchOnesetsselectedIdentifierup front, so a record already cached renders at once. UsefetchTargetto load a record without making it the current one.updateresolves the updated record, or nothing.updateOnestores its answer as the record; an answer that is not a record object (undefined,null, an array) keeps the optimistic patch instead. If your endpoint answers with something else (an acknowledgement), map it inside the operation, or passupdateOne(id, data, { applyResponse: false }).pageTotalandtotalItemscome from thesearchoperation'stotalItems, not a local count, and survive a cache hit.- A missing operation fails at call time, not at setup.
{}is a validoperationsobject. - Slug-addressed backends work.
updateOne(id)/deleteOne(id)passidto the operation exactly as given — a slug included — and the cache resolves it to the real record on its own (seeuseStructureRestApi's alternate-key note): no separate lookup needed first. - Everything underneath is still there: reach for
fetchByParent,fetchTarget,searchGetorresetAllon the same object when a screen needs something this layer does not wrap.
See also
- Your first CRUD screen — this composable in a whole app, step by step.
- Getting Started — app setup and which composable to pick.
useStructureFormValidation— the edit form next to it.