Skip to content

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 ​

KeyTypeDefaultDescription
entitystringrequiredStable identifier. It goes to the activity log as data, so it must not change with the language: 'warehouse', never "Warehouse".
entityLabelstringentityWhat a toast calls the resource. Normally a plural i18n key such as 'warehouses.entity', so "1 warehouse" and "3 warehouses" both read correctly.
baseUrlstringrequiredPath appended to the API base, such as /users.
schemaFieldDefinition[]—Validation derived from the same fields the form renders.
zodSchemaZodSchema—A pre-built schema, for refinements. Wins over schema.
primaryKeykeyof T & string'id'The key that identifies a row.
pagination.perPagenumber10Rows per page.
optimisticbooleanfalseupdate() writes to the list first and rolls back on failure.
activityLogbooleantrueLog create, update and delete. The activity-log resource itself sets false, or it would record its own reads.
toasts.create / .update / .deletestring—Message overrides. Run through uiText, so a key works as well as a sentence.

Returns ​

MemberTypeDescription
itemsRef<T[]>The current page of rows.
itemRef<T | null>What fetchOne loaded.
loadingRef<boolean>A list or single fetch is in flight.
creating / updating / deletingRef<boolean>Separate, so a row's delete spinner does not disable the create button.
paginationreactivepage, perPage and total.
totalPagesComputedRef<number>At least 1, even with no rows.
filtersRef<Record<string, unknown>>Sent as query parameters on every list request.
fieldErrorsRef<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) => voidMerges, resets to page 1, refetches.
resetFilters() => voidClears, resets to page 1, refetches.
changePage(page) => voidSets 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 new fetchAll aborts 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 hideInForm field is server-owned and a visibleIf field that is not on screen has no input, so neither can block a submit. update validates as a partial, which also keeps a sibling-required rule 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. fieldErrors is filled and no toast is shown, because the form is about to light up per field. Every other status is toasted by useApiError.
  • An optimistic update snapshots 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.
  • items is a shallowRef under 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.
  • 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.