useCrud
List, fetch, create, update and delete one resource — with pagination, filters, toasts, activity logging and pre-flight validation already wired.
Import
ts
import { useCrud } from '@core/crud/useCrud'Signature
ts
function useCrud<T extends Record<string, unknown>>(config: CrudConfig<T>): {
items: Ref<T[]>
item: Ref<T | null>
loading: Ref<boolean>
creating: Ref<boolean>
updating: Ref<boolean>
deleting: Ref<boolean>
pagination: { page: number, perPage: number, total: number }
totalPages: ComputedRef<number>
filters: Ref<Record<string, unknown>>
fieldErrors: Ref<ValidationError[]>
fetchAll: () => Promise<void>
fetchOne: (id: T[keyof T]) => Promise<void>
create: (data: Partial<T>) => Promise<T>
update: (id: T[keyof T], data: Partial<T>, opts?: { optimistic?: boolean }) => Promise<T>
remove: (id: T[keyof T]) => Promise<void>
updateFilters: (patch: Record<string, unknown>) => void
resetFilters: () => void
changePage: (page: number) => void
}CrudConfig
| Key | Type | Default | Description |
|---|---|---|---|
entity | string | required | Stable identifier. It goes to the activity log as data, so it must not change with the language: 'warehouse', never "Warehouse". |
entityLabel | string | entity | What a toast calls the resource. Normally a plural i18n key such as 'warehouses.entity', so "1 warehouse" and "3 warehouses" both read correctly. |
baseUrl | string | required | Path appended to the API base, such as /users. |
schema | FieldDefinition[] | — | Validation derived from the same fields the form renders. |
zodSchema | ZodSchema | — | A pre-built schema, for refinements. Wins over schema. |
primaryKey | keyof T & string | 'id' | The key that identifies a row. |
pagination.perPage | number | 10 | Rows per page. |
optimistic | boolean | false | update() writes to the list first and rolls back on failure. |
activityLog | boolean | true | Log create, update and delete. The activity-log resource itself sets false, or it would record its own reads. |
toasts.create / .update / .delete | string | — | Message overrides. Run through uiText, so a key works as well as a sentence. |
Returns
| Member | Type | Description |
|---|---|---|
items | Ref<T[]> | The current page of rows. |
item | Ref<T | null> | What fetchOne loaded. |
loading | Ref<boolean> | A list or single fetch is in flight. |
creating / updating / deleting | Ref<boolean> | Separate, so a row's delete spinner does not disable the create button. |
pagination | reactive | page, perPage and total. |
totalPages | ComputedRef<number> | At least 1, even with no rows. |
filters | Ref<Record<string, unknown>> | Sent as query parameters on every list request. |
fieldErrors | Ref<ValidationError[]> | The 422 payload, shaped { field, message }[]. Hand it to vee-validate's setErrors(). |
fetchAll | () => Promise<void> | Loads the current page with the current filters. |
fetchOne | (id) => Promise<void> | Loads one row into item. |
create | (data) => Promise<T> | Validates, posts, appends, toasts. |
update | (id, data, opts?) => Promise<T> | Validates as a partial, patches, replaces the row, toasts. |
remove | (id) => Promise<void> | Deletes, drops the row, toasts. |
updateFilters | (patch) => void | Merges, resets to page 1, refetches. |
resetFilters | () => void | Clears, resets to page 1, refetches. |
changePage | (page) => void | Sets the page and refetches. |
The list endpoint must answer { data: T[], total: number }.
Example
vue
<script setup lang="ts">
import type { FieldDefinition } from '@core/schema/types'
import { useCrud } from '@core/crud/useCrud'
import { schemaToColumns } from '@core/schema/schemaToColumns'
import DataTable from '@shared/components/common/DataTable/DataTable.vue'
import { onMounted } from 'vue'
interface Warehouse extends Record<string, unknown> {
id: number
name: string
city: string
capacity: number
}
const warehouseSchema: FieldDefinition[] = [
{ key: 'name', type: 'text', label: 'Name', required: true, sortable: true },
{ key: 'city', type: 'text', label: 'City', required: true, sortable: true },
{ key: 'capacity', type: 'number', label: 'Capacity', required: true, validations: { min: 0 } },
]
const {
items,
loading,
pagination,
totalPages,
fetchAll,
create,
updateFilters,
changePage,
} = useCrud<Warehouse>({
entity: 'warehouse',
entityLabel: 'warehouses.entity',
baseUrl: '/warehouses',
schema: warehouseSchema,
pagination: { perPage: 20 },
optimistic: true,
})
const columns = schemaToColumns<Warehouse>(warehouseSchema)
onMounted(fetchAll)
async function addOne() {
await create({ name: 'North dock', city: 'Rotterdam', capacity: 4200 })
}
</script>
<template>
<div class="space-y-4">
<input
type="search"
placeholder="Search"
@input="updateFilters({ q: ($event.target as HTMLInputElement).value })"
>
<DataTable :columns="columns" :data="items" :loading="loading" />
<div class="flex items-center gap-3">
<button type="button" @click="addOne()">
Add
</button>
<span>Page {{ pagination.page }} of {{ totalPages }}</span>
<button type="button" :disabled="pagination.page >= totalPages" @click="changePage(pagination.page + 1)">
Next
</button>
</div>
</div>
</template>Notes
- Rapid filter and page changes cannot race. Each list request carries an
AbortController; a newfetchAllaborts the previous one, and a result that arrives after a newer request started is dropped rather than written. - Validation is narrowed to what the form renders. A
hideInFormfield is server-owned and avisibleIffield that is not on screen has no input, so neither can block a submit.updatevalidates as a partial, which also keeps a sibling-requiredrule from firing on a PATCH of one field. - On a validation failure the first issue is toasted and the Zod error is thrown, so the caller can still catch it.
- 422 is the only status that comes back as data.
fieldErrorsis filled and no toast is shown, because the form is about to light up per field. Every other status is toasted byuseApiError. - An optimistic
updatesnapshots the list first and restores that snapshot if the request fails. - Activity logging is fire-and-forget. A failed audit POST never breaks the operation or its toast.
- Toast text is resolved per call, not once, so a toast fired after a language switch is in the language the reader is looking at.
itemsis ashallowRefunder the hood. Every mutation replaces the array rather than editing it in place, which is what keeps a thousand-row list off the deep-reactivity path.
Related
- Schema — the
FieldDefinition[]this validates against. useSchema— the same fields, as form and table shapes.useApiError— what happens to every other status.useActivityLog— where the audit entries go.- DataTable — the table this usually feeds.