@diephp/vue-model
v1.0.5
Published
Small Vue 3 fetch-based API model helper for forms, collections, and validation errors.
Readme
@diephp/vue-model
Small Vue 3 API model helper around native fetch.
The package is intended for ordinary API-backed Vue forms, tables, selectors, and uploads. It keeps the API surface compact:
- Vue 3 reactivity;
- native
fetch; - stable
model.data; - request helpers;
- validation errors;
- dirty/original snapshot;
- optional auto-send watcher;
FormDataupload helper;- global processing counter.
Runtime dependency: Vue 3 only.
Installation
npm install @diephp/vue-modelVue is a peer dependency:
npm install vueBuild
Build the package into dist:
npm run buildRun only JavaScript bundling:
npm run build:jsRun only type declaration generation:
npm run build:typesWhen the package is packed or published, prepack runs the build automatically:
npm packQuick Start
Create a configured API instance once:
// api.ts
import { createApix } from '@diephp/vue-model'
const apix = createApix()
apix.setBaseUrl('/api')
apix.setHeaders({
'x-app': 'warehouse-panel',
})
apix.transformResponse((ctx) => {
const body = ctx.body
if (!body?.message && Array.isArray(body?.messages)) {
body.message = body.messages[0]?.message ?? body.messages[0] ?? null
}
return body
})
export default apixUse it in a component:
<script setup lang="ts">
import apix from './api'
interface ProductForm {
name: string | null
sku: string | null
price: number | null
}
const product = apix.create<ProductForm>('/products/42', {
default: () => ({
name: null,
sku: null,
price: null,
}),
})
</script>
<template>
<form @submit.prevent="product.post()">
<input v-model="product.data.name">
<div v-if="product.error('name')">{{ product.error('name') }}</div>
<button :disabled="product.processing">
Save
</button>
</form>
</template>create() immediately returns a stable reactive model. model.data starts from default and is replaced/merged after the server response arrives.
Core Idea
@diephp/vue-model treats an API resource as a small reactive model:
const model = apix.create('/orders/1001', {
default: () => ({
status: null,
customer_id: null,
items: [],
}),
})You read and edit:
model.data.status = 'confirmed'You can reload or change the resource:
await model.reload()
await model.post('/orders')
await model.put({ omit: ['items'] })
await model.patch({ only: ['status'] })send() is the same as refresh(). For a JSON model it uses the model method, and the default model method is GET. That makes send() convenient for search/filter models, but for changing a resource it is usually clearer to call post, put, or patch.
You show errors:
<el-form-item :error="model.error('status')">
<el-select v-model="model.data.status" />
</el-form-item>
<ul v-if="model.errors('status').length">
<li v-for="error in model.errors('status')" :key="error">
{{ error }}
</li>
</ul>You check dirty state:
model.isDirty()
model.isDirty('status')
model.getDirty('status')
model.reset()There is no proxy shortcut like model.name. Use model.data.name.
Creating An Instance
import { createApix } from '@diephp/vue-model'
const apix = createApix()apix.setBaseUrl(url)
Sets the base URL for relative model URLs.
apix.setBaseUrl('/admin/api')
const profile = apix.create('/profile')
// requests /admin/api/profileIf the model URL starts with ?, it is appended to the base URL:
apix.setBaseUrl('/admin/products.php')
const products = apix.create('?action=list_products')
// requests /admin/products.php?action=list_productsAbsolute URLs are used as-is:
const external = apix.create('https://example.com/api/status')apix.setHeaders(headers)
Replaces global headers:
apix.setHeaders({
'x-app': 'inventory',
authorization: `Bearer ${token}`,
})apix.setHeader(name, value)
Sets one global header:
apix.setHeader('authorization', `Bearer ${token}`)apix.setHeader(headers)
Merges several global headers:
apix.setHeader({
'x-locale': 'en',
'x-timezone': 'Europe/Madrid',
})apix.transformResponse(transformer)
Adds a global response transformer. It runs for successful and failed HTTP responses.
apix.transformResponse((ctx) => {
const body = ctx.body
if (!body?.message && Array.isArray(body?.messages)) {
body.message = body.messages[0]?.message ?? body.messages[0] ?? null
}
return body
})The transformer receives:
{
body,
ok,
status,
statusText,
headers,
url,
method,
response,
}Transformers can be async:
apix.transformResponse(async (ctx) => {
return ctx.body
})Global Processing
Each createApix() instance tracks all active requests:
apix.activeRequests // number
apix.processing // booleanExample global spinner:
<template>
<main v-loading="apix.processing">
<RouterView />
</main>
</template>apix.processing is true while at least one model created by that apix instance is making a request.
Direct Requests
You can make one-off requests without creating a model.
await apix.get('/profile')
await apix.post('/signup')
await apix.put('/settings')
await apix.patch('/profile')
await apix.delete('/sessions/current')Direct requests use the same base URL, headers, fetch, timeout, response transform, and error handling as models.
Params
For GET, params become query params:
const countries = await apix.get('/countries', {
params: {
active: true,
locale: 'en',
},
})Request:
GET /countries?active=true&locale=enFor body methods, params become JSON body:
await apix.post('/newsletter/subscribe', {
params: {
email: '[email protected]',
},
})Request body:
{
"email": "[email protected]"
}Body
Use body when you want full control over the request body:
await apix.post('/auth/login', {
body: {
email: '[email protected]',
password: 'secret',
},
})FormData also works:
const formData = new FormData()
formData.append('file', file)
await apix.post('/files/upload', {
body: formData,
})Callbacks
Direct requests support the same callbacks:
await apix.post('/signup', {
params: {
email: '[email protected]',
},
onSuccess: (body) => {
console.log(body)
},
onError: (error) => {
console.log(error.message)
},
})Return Value
Direct requests return a promise with the response data:
const profile = await apix.get<{ id: number; name: string }>('/profile')Failed requests reject with ApixError:
try {
await apix.post('/signup', {
params: {
email: '',
},
})
} catch (error) {
if (error instanceof ApixError) {
console.log(error.error('email'))
}
}Factory Methods
apix.create<T>(url, options?)
Creates a JSON model.
const invoice = apix.create('/invoices/77', {
default: () => ({
number: null,
amount: null,
status: null,
}),
})apix.createModel<T>(url, options?)
Alias to apix.create().
const invoice = apix.createModel('/invoices/77')apix.form<T>(defaults?, options?)
Creates a local JSON form model without an initial request and without a URL.
Use this for registration, login, signup, filters, modal forms, and any form where you already know the initial fields and only need to submit later.
const form = apix.form({
first_name: '',
last_name: '',
email: '',
phone: '',
country: '',
password: '',
password_confirmation: '',
terms: false,
})This is equivalent to a JSON model with:
{
default: () => defaults,
immediate: false,
}Because there is no URL, pass the URL when you submit:
await form.post('/signup')Or:
await form.post('/signup', {
only: ['email', 'password', 'password_confirmation', 'terms'],
})Empty form:
const subscription = apix.form({})With options:
const login = apix.form(
{
email: '',
password: '',
},
{
timeout: 5000,
onError: (error) => {
console.log(error.message)
},
},
)apix.form() is for JSON data. For upload forms with FormData, use apix.createForm().
apix.createCollection<TItem>(url, options?)
Sugar for list/selector data.
const cityOptions = apix.createCollection<{ id: number; name: string }>(
'?action=get_cities',
)Internally this is equivalent to:
apix.create(url, {
default: () => [],
snapshot: false,
...options,
})Use it when you need data and processing, but do not need dirty/reset/original tracking.
apix.createForm(url, options?)
Creates a FormData model for uploads.
const uploadForm = apix.createForm('/documents/upload')
uploadForm.data.append('meta', JSON.stringify({ folder_id: 10 }))
uploadForm.data.append('file', file)
await uploadForm.send()createForm() does not mix JSON and FormData. If you need JSON metadata with a file, put the metadata into the form:
uploadForm.data.append('meta', JSON.stringify({
title: 'Monthly report',
category: 'finance',
}))
uploadForm.data.append('file', file)For FormData, the package does not set content-type. The browser sets multipart/form-data with the correct boundary.
The JSON response from an upload is not merged into form.data, because form.data must remain FormData. Use onSuccess when you need the upload response body.
const uploadForm = apix.createForm('/documents/upload', {
onSuccess: (body) => {
console.log(body)
},
})
await uploadForm.send()Model State
Every model exposes:
model.data
model.processing
model.httpCode
model.messagemodel.data
Reactive model data.
model.data.name = 'Express scanner'For JSON models, data starts from default.
For createForm, data is a FormData object.
model.processing
true while this model has an active request.
<el-button :loading="product.processing">
Save
</el-button>model.httpCode
Last HTTP status code.
model.httpCode // 200, 422, 500, nullmodel.message
Last error message, if any.
<el-alert v-if="model.message" :title="model.message" />Successful requests clear message.
Model Options
apix.create('/products/42', {
params: { locale: 'en' },
path: 'item',
headers: { 'x-screen': 'product-edit' },
immediate: true,
method: 'POST',
default: () => ({ name: null }),
only: ['name'],
omit: ['local_state'],
response: { omit: ['name'] },
watch: { only: ['pagination.page'], debounce: 300 },
timeout: 5000,
abort: true,
locked: false,
snapshot: true,
clearErrorOnChange: true,
before: (payload) => payload,
after: (body) => body,
onSuccess: (body, model) => {},
onError: (error, model) => {},
onFinish: (model, error) => {},
onProcessing: (active, model) => {},
})default
Object or callback used as initial data.
default: () => ({
name: null,
status: 'draft',
items: [],
})If not provided, data starts as null.
default is used:
- when the model is created;
- when
model.default()is called; - as the base for
reload()merge.
immediate
Controls whether the model sends the first request immediately.
immediate: true // default for JSON models
immediate: falseUse immediate: false for create forms or when you want to wait for a user action.
const search = apix.create('/parcels/search', {
immediate: false,
default: () => ({
tracking_number: null,
parcel: null,
}),
})
await search.send()createForm() defaults to immediate: false.
params
Extra request params. Can be an object or callback.
params: {
warehouse_id: 15,
}For GET and HEAD, params are added to the query string.
For body methods, params are merged into the payload.
Callbacks run right before the request:
params: () => ({
route_id: route.query.id,
locale: i18n.locale.value,
})headers
Request headers for this model/request.
headers: {
'x-module': 'scanner-editor',
}Can be a callback:
headers: () => ({
authorization: `Bearer ${auth.token}`,
})Request headers override global headers with the same name.
method
Default method for requests made by request, send, refresh, and reload.
For JSON models, the default is GET.
For createForm(), the default is POST.
method: 'PATCH'Explicit method helpers override the default:
await model.post() // POST
await model.put() // PUT
await model.patch() // PATCHRequest options can override even a helper method:
await model.post({ method: 'GET' })The request option has priority.
path
Path inside the response body where model data lives.
const product = apix.create('/products/42', {
path: 'item',
})If the response is:
{
"item": {
"id": 42,
"name": "Label printer"
}
}then model.data receives:
{
id: 42,
name: 'Label printer',
}path is not the request URL. The request URL is the first argument passed to create, request, get, post, etc.
only
Filters the payload sent to the server.
only: ['name', 'pagination.page', 'pagination.onPage']If you send:
await model.send()only those fields are included in the request body.
If you specify an object path:
only: ['pagination']the whole pagination object is sent.
only does not filter the response.
omit
Excludes fields from the payload sent to the server.
omit: ['items', 'local_state']Fields in omit are also excluded from the original snapshot. That means:
isDirty('items')is alwaysfalse;getOriginal('items')isundefined;reset()does not changeitems.
Use snapshot: false when you want to disable snapshot tracking completely.
response.only and response.omit
Filters successful response data before it is applied to model.data.
Default: successful response data is applied completely.
Example: send filters, but do not let the response overwrite a text input:
await model.send({
only: ['name', 'pagination'],
response: {
omit: ['name'],
},
})Example: only apply table data from the response:
await model.send({
response: {
only: ['items', 'pagination'],
},
})Error responses do not apply to model.data.
For validation endpoints, send only the fields needed for validation and apply only the status fields returned by the server.
watch(
() => [form.data.slug, form.data.domain],
debounce(() => {
if (!form.data.slug || !form.data.domain) return
form.post('/validate-domain', {
only: ['slug', 'domain'],
response: {
only: ['message', 'status'],
},
}).catch(() => undefined)
}, 1000),
)This keeps slug and domain as user-controlled fields, while message and status can be shown in the UI.
watch
Automatically sends the model when watched fields change.
watch: truewatch: {
debounce: 300,
}By default, watch uses the model payload filter:
only: ['name', 'pagination.page'],
watch: trueThis watches name and pagination.page.
Override watched fields:
only: ['name', 'pagination.page', 'pagination.onPage'],
watch: {
only: ['pagination.page', 'pagination.onPage'],
}Now name is still sent when the request runs, but changing name does not start the request.
Use watch.omit:
watch: {
omit: ['name', 'items'],
}watch.only: [] means watch nothing.
When the package applies a response, reset, or default, the watcher is muted to avoid request loops.
timeout
Aborts a request after the given number of milliseconds.
timeout: 5000Timeout errors reject with ApixError:
try {
await model.send()
} catch (error) {
if (error.timeout) {
console.log('Request timed out')
}
}abort
Default: true.
abort: trueWhen a new request starts, the previous active request for this model is aborted.
locked
Default: false.
locked: trueIf a request is already active, a new request does not start. The current promise is returned instead.
Priority:
locked: trueblocks new requests while processing;locked: false, abort: trueaborts the previous request;locked: false, abort: falseallows concurrent requests, but only the latest response applies to the model.
snapshot
Default for JSON models: true.
Default for createCollection and createForm: false.
snapshot: falseDisables original copy storage and dirty/reset tracking.
Useful for selector options:
const countryOptions = apix.createCollection('/countries/options')or:
const countryOptions = apix.create('/countries/options', {
default: () => [],
snapshot: false,
})clearErrorOnChange
Default: true.
When a field changes, the matching field error is cleared.
clearErrorOnChange: trueExample:
model.error('email') // "Email is required"
model.data.email = '[email protected]'
model.error('email') // nullThis works independently of watch.
before
Transforms payload before sending it. It does not mutate model.data unless you do so manually.
before: (payload) => {
return {
...payload,
normalized_name: payload.name?.trim(),
}
}With context:
before: (payload, ctx) => {
console.log(ctx.url, ctx.method)
return payload
}after
Transforms successful response data before it is applied to model.data.
after: (body) => {
return {
...body,
loaded_at: Date.now(),
}
}after runs after path is applied and before response.only/omit.
onSuccess
Called after a successful response.
onSuccess: (body, model) => {
console.log('Saved', body)
}onError
Called after a failed response.
onError: (error, model) => {
console.log(error.message)
console.log(error.error('name'))
}onFinish
Called after success or failure.
onFinish: (model, error) => {
console.log(error ? 'failed' : 'finished')
}onProcessing
Called when model processing starts and stops.
onProcessing: (active) => {
progressBar.toggle(active)
}Request Methods
All request methods return Promise<TData>.
await model.request()
await model.get()
await model.post()
await model.put()
await model.patch()
await model.delete()
await model.reload()
await model.refresh()
await model.send()send() and refresh()
send() is exactly the same method as refresh().
model.send === model.refresh // trueBoth methods:
- send current
model.data; - use the model method;
- use
GETby default for JSON models; - apply
only/omitto the request payload; - merge a successful response into current
model.data; - update the original snapshot after success.
Use whichever name reads better:
await filterModel.refresh()
await searchModel.send()For changing a resource, prefer explicit methods:
await productForm.post()
await productForm.put()
await productForm.patch({ only: ['status'] })Argument Forms
Request methods accept either a URL, options, or both:
await model.post()
await model.post('/products')
await model.post({ only: ['name'] })
await model.post('/products', { only: ['name'] })You can also pass the URL inside options:
await model.post({
url: '/products',
only: ['name'],
})Options passed to a request override model options for that one request:
const product = apix.create('/products/42', {
only: ['name', 'price'],
})
await product.patch({
only: ['status'],
})In this request only status is sent.
Method Override
The method helpers are convenient defaults. options.method has the highest priority:
await model.post({
method: 'GET',
only: ['name'],
})This sends a GET request, not POST.
This is mostly useful when a model has a configured method and you want to override it once:
const report = apix.create('/reports/export', {
method: 'POST',
})
await report.send() // POST
await report.send({ method: 'GET' }) // GETPayload Rules
get, post, put, patch, delete, request, send, and refresh are send-style methods. They use current model.data as payload.
For GET and HEAD, the payload becomes query params:
model.data.name = 'phone'
model.data.pagination.page = 2
await model.get({
only: ['name', 'pagination.page'],
})
// GET /...?name=phone&pagination.page=2For body methods, the payload is JSON:
await model.patch({
only: ['name', 'price'],
})The body will be:
{
"name": "Desk",
"price": 120
}body overrides automatic payload building:
await model.patch({
body: model.getDirty(),
})When body is passed, only, omit, and before are not used to build the payload.
reload() Is Different
reload() means "load model data again". It does not send current model.data.
await model.reload()If you need params for reload, use params:
await model.reload({
params: {
page: 2,
onPage: 50,
},
})For reload(), the successful response is merged with fresh default() data:
data = merge(default(), responseData)For send() / refresh() / post() / patch() / etc., the successful response is merged with current data:
data = merge(currentData, responseData)model.request(url?, options?)
Generic request method.
await model.request('/products/search', {
method: 'POST',
only: ['name'],
})model.get(url?, options?)
Sends a GET request.
await model.get('/products/42')For GET, payload and params are converted to query params.
Examples:
await model.get({
only: ['name', 'pagination.page'],
})await model.get('/products/search', {
params: {
warehouse_id: 10,
},
only: ['name'],
})If model.data.name is "phone", this builds a query similar to:
/products/search?name=phone&warehouse_id=10model.post(url?, options?)
Sends a POST request.
await model.post('/products')Examples:
await model.post({
only: ['name', 'sku', 'price'],
})await model.post('/products/create', {
omit: ['items', 'local_state'],
})await model.post({
url: '/products/create',
response: {
omit: ['name'],
},
})model.put(url?, options?)
Sends a PUT request.
await model.put('/products/42')Examples:
await model.put({
only: ['name', 'status', 'settings'],
})await model.put('/products/42', {
omit: ['items', 'logs'],
})model.patch(url?, options?)
Sends a PATCH request.
await model.patch('/products/42', {
only: ['name', 'price'],
})Patch with only dirty data:
await model.patch({
body: model.getDirty(),
})Patch one nested value:
await model.patch({
only: ['settings.resource.limit'],
})model.delete(url?, options?)
Sends a DELETE request.
await model.delete('/products/42')Examples:
await model.delete()await model.delete('/products/42', {
params: {
force: true,
},
})await model.delete({
url: '/products/42',
only: ['reason'],
})model.reload(options?)
Loads data from the server.
Default method: GET.
Merge rule:
model.data = merge(default(), responseData)Example:
await product.reload()Useful when you want the server to become the source of truth again.
Examples:
await product.reload({
params: {
include: 'items,logs',
},
})await product.reload({
response: {
only: ['name', 'status', 'items'],
},
})reload() does not send current model.data. Use params for extra request data.
model.refresh(options?)
Sends current model data to the server.
Default method for JSON models: GET.
Merge rule:
model.data = merge(currentData, responseData)Example:
await searchModel.refresh()Examples:
await searchModel.refresh({
only: ['name', 'price'],
})This sends name and price as query params unless the model or request sets another method.
await productForm.refresh({
method: 'PATCH',
omit: ['items', 'logs'],
response: {
omit: ['name'],
},
})model.send(options?)
Alias to refresh().
await searchModel.send()This often reads better for search/filter models.
Examples:
await search.send({
only: ['name', 'price'],
})await productForm.send({
method: 'PATCH',
body: productForm.getDirty(),
})Data Methods
model.setData(data)
Replaces model.data.
model.setData({
name: 'New scanner',
status: 'active',
})Use this when you want to replace the whole model manually:
model.setData({
name: null,
items: [],
pagination: {
page: 1,
onPage: 50,
total: 0,
},
})model.push(data)
For objects, deep-merges data into model.data.
model.push({
status: 'active',
})Nested object example:
model.push({
pagination: {
page: 1,
},
})Only pagination.page changes. Other pagination fields stay as they are.
For arrays, pushes one item.
const options = apix.createCollection('/tag-options')
options.push({
id: 10,
label: 'Fragile',
})model.reset(options?)
Resets data to the latest successful original snapshot.
model.reset()Reset one field:
model.reset({
only: ['name'],
})Reset nested fields:
model.reset({
only: ['settings.resource.limit', 'pagination.page'],
})Reset everything except one field:
model.reset({
omit: ['pagination.page'],
})This is useful when a table should reset filters but keep the current page.
Fields excluded by the model-level omit are not stored in the snapshot and are not changed by reset().
model.default(options?)
Resets data to default.
model.default()Reset selected fields to default:
model.default({
only: ['status', 'type'],
})Reset one nested field to default:
model.default({
only: ['pagination.page'],
})Reset defaults but keep local UI data:
model.default({
omit: ['items', 'local_state'],
})Dirty And Original Snapshot
Successful requests update the original snapshot:
await model.send()
model.isDirty() // falseFailed requests do not update the snapshot:
try {
await model.send()
} catch {}
model.isDirty() // still true if data changedmodel.isDirty(key?)
Checks whether data differs from the latest snapshot.
model.isDirty()
model.isDirty('name')
model.isDirty('settings.resource.limit')Example:
if (model.isDirty()) {
await model.send()
}Field example:
if (model.isDirty('pagination.page')) {
await model.get({
only: ['pagination.page'],
})
}model.getOriginal(key?)
Returns the latest snapshot.
model.getOriginal()
model.getOriginal('name')Example:
const was = model.getOriginal('status')
const now = model.data.statusNo default values are silently mixed into getOriginal.
If snapshot: false, this returns null without a key and undefined with a key.
model.getDirty(key?)
Returns changed current values.
model.getDirty()
model.getDirty('name')Example return value:
model.data = {
name: 'Scanner B',
status: 'active',
pagination: {
page: 2,
onPage: 50,
},
}
model.getDirty()Possible result:
{
"name": "Scanner B",
"pagination": {
"page": 2
}
}If the field is not dirty:
model.getDirty('name') // undefinedErrors
Error responses are not merged into model.data.
If the server returns:
{
"message": "Validation failed",
"errors": {
"name": [
"Name is required"
],
"settings.resource.limit": [
"Limit is too high"
]
}
}then:
model.message // "Validation failed"
model.error('name') // "Name is required"
model.errors('name') // ["Name is required"]
model.error(['name', 'settings.resource.limit']) // "Name is required"
model.errors(['name', 'settings.resource.limit']) // ["Name is required", "Limit is too high"]
model.errorKeys() // ["name", "settings.resource.limit"]model.error(key)
Returns the first error string for a field or null.
<el-form-item :error="product.error('name')">Nested key:
<el-form-item :error="product.error('settings.resource.limit')">List of keys:
<el-form-item :error="product.error(['slug', 'domain'])">This returns the first error found in the same order as the keys.
model.errors(key)
Returns all error strings for a field.
model.errors('name')You can pass several keys. The result is one flat array:
model.errors(['slug', 'domain'])Useful when one UI input visually represents more than one API field:
<form-input
v-model="resourceForm.data.slug"
:disabled="resourceForm.processing"
required
:errors="resourceForm.errors(['slug', 'domain'])"
label="Slug"
/>
<form-simple-select
v-model="resourceForm.data.domain"
required
:options="domains.data"
label="Domain"
/>Show all field messages:
<ul v-if="model.errors('name').length">
<li v-for="message in model.errors('name')" :key="message">
{{ message }}
</li>
</ul>If the key does not exist, returns an empty array.
model.errors()
Returns the raw errors object.
model.errors()model.errorKeys()
Returns error paths.
model.errorKeys()Useful for forms split into tabs.
const keys = model.errorKeys()
if (keys[0]?.startsWith('settings.')) {
activeTab.value = 'settings'
}model.hasErrors(keyOrKeys)
Checks whether one field or any field from a list has an error.
model.hasErrors('name')
model.hasErrors(['name', 'settings.resource.limit'])Example tab switch:
if (model.hasErrors(['name', 'slug'])) {
activeTab.value = 'general'
}
if (model.hasErrors(['settings.resource.limit', 'settings.weight.limit'])) {
activeTab.value = 'limits'
}This returns true if at least one key from the list has an error.
model.firstErrorKey()
Returns the first error path or null.
const firstKey = model.firstErrorKey()model.getError()
Returns the latest ApixError or null.
const error = model.getError()
if (error?.has(['name', 'slug'])) {
activeTab.value = 'general'
}Useful when you want to inspect the raw response:
const error = model.getError()
console.log(error?.httpCode)
console.log(error?.body)
console.log(error?.keys())model.clearError(key)
Clears one field error.
model.clearError('name')Nested key:
model.clearError('settings.resource.limit')model.clearErrors()
Clears all errors and message.
model.clearErrors()Useful before closing a modal or changing a large part of a form manually.
clearErrorOnChange
By default, editing a field clears that field error:
model.data.name = 'New name'
model.error('name') // nullThis works even when watch is disabled.
ApixError
Requests reject with ApixError.
import { ApixError } from '@diephp/vue-model'
try {
await model.send()
} catch (error) {
if (error instanceof ApixError) {
console.log(error.httpCode)
console.log(error.message)
}
}Properties:
error.httpCode
error.body
error.message
error.aborted
error.timeout
error.causeMethods:
error.error('name')
error.error(['name', 'slug'])
error.errors('name')
error.errors(['name', 'slug'])
error.errors()
error.keys()
error.has('name')
error.has(['name', 'slug'])
error.firstKey()Keys And Filters
Many methods and options accept keys:
'name'
'pagination'
'pagination.page'
'settings.resource.limit'The same key format works in:
only;omit;response.only;response.omit;watch.only;watch.omit;error(key);errors(key);hasErrors(keyOrKeys);isDirty(key);getDirty(key);getOriginal(key);reset({ only, omit });default({ only, omit }).
For errors, key can be a single key or a list of keys:
model.error('name')
model.error(['slug', 'domain'])
model.errors('name')
model.errors(['slug', 'domain'])Plain Key
only: ['name']Given:
model.data = {
name: 'Scanner A',
status: 'active',
}the request payload is:
{
"name": "Scanner A"
}Whole Object Key
only: ['pagination']Given:
model.data = {
name: 'Scanner A',
pagination: {
page: 2,
onPage: 50,
total: 300
}
}the request payload is:
{
"pagination": {
"page": 2,
"onPage": 50,
"total": 300
}
}Use this when the server expects the whole object.
Nested Key
only: ['pagination.page']Given the same data, the request payload is:
{
"pagination": {
"page": 2
}
}Use this when the server only needs one nested value.
Multiple Keys
await model.send({
only: [
'name',
'category_id',
'pagination.page',
'pagination.onPage',
],
})Payload:
{
"name": "Scanner A",
"category_id": 7,
"pagination": {
"page": 2,
"onPage": 50
}
}Excluding Keys With omit
await model.send({
omit: ['items', 'debug', 'local_state'],
})Everything except these keys is sent.
Nested omit:
await model.send({
omit: ['pagination.total'],
})This keeps pagination.page and pagination.onPage, but removes pagination.total.
Combining only And omit
only is applied first, then omit.
await model.send({
only: ['name', 'pagination'],
omit: ['pagination.total'],
})Payload:
{
"name": "Scanner A",
"pagination": {
"page": 2,
"onPage": 50
}
}Empty Lists
Empty arrays are meaningful:
only: []means "send nothing from model.data".
omit: []means "exclude nothing".
watch: {
only: [],
}means "watch nothing".
To avoid storing a snapshot, use:
snapshot: falseDo not use omit: [] for that. Empty omit means no keys are omitted.
Response Keys
only and omit filter request payload.
response.only and response.omit filter successful response data.
await model.send({
only: ['name', 'pagination'],
response: {
omit: ['name'],
},
})This sends name and pagination, but the response is not allowed to overwrite name.
await model.send({
response: {
only: ['items', 'pagination.total'],
},
})This applies only items and pagination.total from the successful response.
Watch Keys
If watch.only and watch.omit are not passed, watch uses the model request filter.
const table = apix.create('/stock/table', {
only: ['name', 'pagination.page', 'pagination.onPage'],
watch: true,
})Changing name, pagination.page, or pagination.onPage starts a request.
Override watched keys:
const table = apix.create('/stock/table', {
only: ['name', 'pagination.page', 'pagination.onPage'],
watch: {
only: ['pagination.page', 'pagination.onPage'],
},
})Now name is still sent in the request, but editing name does not start a request.
Use watch.omit when it is easier to exclude a few fields:
watch: {
omit: ['name', 'items'],
}Error Keys
Laravel-style errors often use dot paths:
{
"message": "Validation failed",
"errors": {
"settings.resource.limit": [
"Limit is too high"
],
"items.0.name": [
"Item name is required"
]
}
}Read them directly:
model.error('settings.resource.limit')
model.errors('items.0.name')
model.hasErrors(['name', 'settings.resource.limit'])For tabbed forms:
if (model.hasErrors(['name', 'sku'])) {
activeTab.value = 'general'
}
if (model.hasErrors(['settings.resource.limit', 'settings.weight.limit'])) {
activeTab.value = 'limits'
}
if (model.hasErrors(['items'])) {
activeTab.value = 'items'
}Dirty Keys
model.isDirty('name')
model.isDirty('pagination.page')
model.getDirty('name')
model.getOriginal('name')Reset selected values:
model.reset({
only: ['name', 'pagination.page'],
})Reset everything except current page:
model.reset({
omit: ['pagination.page'],
})Keys That Contain Dots
Keys with dots are supported. Exact keys have priority at each level.
For:
const data = {
settings: {
'resource.limit': 10,
},
}this key works:
model.isDirty('settings.resource.limit')The helper first tries:
data['settings.resource.limit']then:
data.settings['resource.limit']then:
data.settings.resource.limitIf the server returns both exact-dot keys and nested structures for the same path, the data is ambiguous. The package will still choose a value predictably, but the API response should be fixed.
Arrays In Keys
For error lookup, array-like Laravel keys are fine:
model.error('items.0.name')For request payload filters, prefer selecting the whole array:
only: ['items']instead of selecting one array item:
only: ['items.0.name']The package is optimized for JSON form objects. Sending whole arrays is usually clearer and safer.
Lifecycle And stop()
If a model is created inside a Vue setup() scope, it is automatically stopped when the component is unmounted.
<script setup lang="ts">
const model = apix.create('/profile')
</script>On unmount:
- watcher is stopped;
- debounce timer is cleared;
- active request is aborted;
- delayed
immediaterequest will not start.
If you create a model outside a component scope, stop it manually when you no longer need it:
const model = apix.create('/reports/live', {
watch: true,
})
model.stop()Calling request methods after stop() rejects with an ApixError where aborted is true.
Examples
Edit Resource Form
<script setup lang="ts">
import apix from './api'
interface DeviceForm {
name: string | null
serial_number: string | null
status: 'active' | 'disabled' | null
}
const deviceForm = apix.create<DeviceForm>('/devices/120', {
path: 'item',
default: () => ({
name: null,
serial_number: null,
status: null,
}),
})
const save = async () => {
await deviceForm.send({
only: ['name', 'serial_number', 'status'],
})
}
</script>
<template>
<el-form v-loading="deviceForm.processing">
<el-form-item label="Name" :error="deviceForm.error('name')">
<el-input v-model="deviceForm.data.name" />
</el-form-item>
<el-form-item label="Serial number" :error="deviceForm.error('serial_number')">
<el-input v-model="deviceForm.data.serial_number" />
</el-form-item>
<el-form-item label="Status" :error="deviceForm.error('status')">
<el-select v-model="deviceForm.data.status">
<el-option label="Active" value="active" />
<el-option label="Disabled" value="disabled" />
</el-select>
</el-form-item>
<el-button type="primary" :loading="deviceForm.processing" @click="save">
Save
</el-button>
</el-form>
</template>Create Form Without Immediate Request
const createWarehouse = apix.create('/warehouses', {
immediate: false,
default: () => ({
name: '',
country_id: null,
city_id: null,
}),
})
await createWarehouse.post()Local Signup Form
const signupForm = apix.form({
first_name: '',
last_name: '',
email: '',
phone: '',
country: '',
password: '',
password_confirmation: '',
terms: false,
})
const submit = async () => {
await signupForm.post('/auth/signup')
}Use it in UI like any other model:
<el-form-item label="Email" :error="signupForm.error('email')">
<el-input v-model="signupForm.data.email" />
</el-form-item>
<el-form-item label="Password" :error="signupForm.error('password')">
<el-input v-model="signupForm.data.password" type="password" />
</el-form-item>
<el-checkbox v-model="signupForm.data.terms">
I accept terms
</el-checkbox>
<el-button :loading="signupForm.processing" @click="submit">
Create account
</el-button>You can still use request options:
await signupForm.post('/auth/signup', {
only: [
'first_name',
'last_name',
'email',
'password',
'password_confirmation',
'terms',
],
})Search Form
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import apix from './api'
const route = useRoute()
const router = useRouter()
const initialQuery = String(route.query.query ?? '')
const searchForm = apix.create('/catalog/search', {
immediate: Boolean(initialQuery),
default: () => ({
query: initialQuery,
product: null,
history: null,
}),
only: ['query'],
omit: ['product', 'history'],
params: () => ({
query: searchForm.data.query || undefined,
}),
onError: () => {
searchForm.push({
product: null,
history: null,
})
},
onFinish: () => {
router.replace({
query: {
...route.query,
query: searchForm.data.query || undefined,
},
})
},
})
</script>
<template>
<el-form-item :error="searchForm.error('query')">
<el-input
v-model="searchForm.data.query"
placeholder="Search product"
clearable
/>
</el-form-item>
<el-button
type="primary"
:disabled="!searchForm.data.query"
:loading="searchForm.processing"
@click="searchForm.send()"
>
Search
</el-button>
</template>Table With Filters And Pagination
Changing page sends the request automatically. Changing name does not send until the user clicks search.
const tableModel = apix.create('/inventory/table', {
default: () => ({
name: null,
category_id: null,
items: [],
pagination: {
page: 1,
onPage: 50,
total: 0,
},
}),
only: ['name', 'category_id', 'pagination.page', 'pagination.onPage'],
watch: {
only: ['pagination.page', 'pagination.onPage'],
debounce: 250,
},
})
const search = () => {
tableModel.send()
}Payload includes name, category_id, pagination.page, and pagination.onPage.
Only pagination.page and pagination.onPage trigger automatic requests.
Filters That Auto-Refresh
const ruleModel = apix.create('/shipping/rules', {
default: () => ({
name: null,
carrier_ids: [],
country_ids: [],
service_ids: [],
items: [],
pagination: {
page: 1,
onPage: 50,
total: 0,
},
}),
only: [
'name',
'carrier_ids',
'country_ids',
'service_ids',
'pagination.page',
'pagination.onPage',
],
watch: {
debounce: 300,
},
})Because watch.only is not provided, watcher uses the model only list.
Avoid Input Jump From Server Response
If a server returns filter fields back, you may not want to overwrite the current user input.
await tableModel.send({
only: ['name', 'pagination'],
response: {
omit: ['name'],
},
})The request sends name and pagination. The response applies everything except name.
Apply Only Table Data From Response
await tableModel.send({
response: {
only: ['items', 'pagination'],
},
})Useful when the response contains metadata you do not want to store in the model.
Selector Options
<script setup lang="ts">
import apix from './api'
const hubOptions = apix.createCollection<{ id: number; name: string }>(
'/hubs/options',
)
</script>
<template>
<el-select
v-model="scannerForm.data.hub_id"
v-loading="hubOptions.processing"
placeholder="Select hub"
>
<el-option
v-for="hub in hubOptions.data"
:key="hub.id"
:label="hub.name"
:value="hub.id"
/>
</el-select>
</template>createCollection uses snapshot: false, so it does not store a second copy of the options.
Several Selectors On One Page
const hubOptions = apix.createCollection('/hubs/options')
const roleOptions = apix.createCollection('/roles/options')
const countryOptions = apix.createCollection('/countries/options')
const timezoneOptions = apix.createCollection('/timezones/options')Use the global spinner if you want to wait for all of them:
<section v-loading="apix.processing">Or use each model's own spinner:
<el-select v-loading="countryOptions.processing" />File Upload
<script setup lang="ts">
import apix from './api'
const upload = apix.createForm('/files/upload', {
onSuccess: (body) => {
console.log('Uploaded file', body)
},
})
const uploadFile = async (file: File) => {
upload.data.set('meta', JSON.stringify({
folder: 'contracts',
visibility: 'private',
}))
upload.data.set('file', file)
await upload.send()
}
</script>Laravel Validation Errors
Laravel usually returns:
{
"message": "The given data was invalid.",
"errors": {
"email": [
"The email field is required."
]
}
}Use directly:
<el-form-item label="Email" :error="userForm.error('email')">
<el-input v-model="userForm.data.email" />
</el-form-item>Custom Error Format
If another project returns messages, normalize it once:
apix.transformResponse((ctx) => {
const body = ctx.body
if (!body.message && Array.isArray(body.messages)) {
const first = body.messages[0]
body.message = typeof first === 'string' ? first : first?.message ?? null
}
return body
})Switch Tabs By Error Group
const openErrorTab = () => {
if (productForm.hasErrors(['name', 'sku', 'barcode'])) {
activeTab.value = 'general'
return
}
if (productForm.hasErrors(['settings.resource.limit', 'settings.weight.limit'])) {
activeTab.value = 'limits'
return
}
if (productForm.hasErrors(['items'])) {
activeTab.value = 'items'
}
}
try {
await productForm.patch()
} catch {
openErrorTab()
}Dirty Buttons
<el-button :disabled="!productForm.isDirty()" @click="productForm.reset()">
Reset
</el-button>
<el-button type="primary" :disabled="!productForm.isDirty()" @click="productForm.patch()">
Save
</el-button>Send Only Dirty Data
The package does not force this pattern, but you can do it explicitly:
await productForm.patch({
body: productForm.getDirty(),
})Manual Error Handling
import { ApixError } from '@diephp/vue-model'
try {
await productForm.patch()
} catch (error) {
if (error instanceof ApixError) {
if (error.httpCode === 422) {
console.log(error.keys())
}
if (error.timeout) {
console.log('Try again later')
}
}
}Locked Submit Button
const paymentForm = apix.create('/payments/charge', {
immediate: false,
locked: true,
default: () => ({
amount: null,
card_id: null,
}),
})If the user clicks twice, the second request will not start while the first one is active.
Abort Previous Search Request
Default behavior:
const search = apix.create('/products/search', {
abort: true,
watch: {
only: ['query'],
debounce: 200,
},
default: () => ({
query: null,
items: [],
}),
})When the user types quickly, the previous active request is aborted before the new one starts.
Request Params From Route
const report = apix.create('/reports/monthly', {
params: () => ({
month: route.query.month,
warehouse_id: route.query.warehouse_id,
}),
default: () => ({
items: [],
totals: {},
}),
})The callback runs before every request, so route/query values stay current.
Response Wrapper
const profile = apix.create('/profile/current', {
path: 'data.user',
default: () => ({
id: null,
name: null,
email: null,
}),
})Response:
{
"data": {
"user": {
"id": 15,
"name": "Anna",
"email": "[email protected]"
}
}
}Model data:
profile.data.name // "Anna"Merge Rules
Initial data
data = default()reload()
data = merge(default(), responseData)Use reload() when you want to rebuild data from server state.
send() / refresh()
data = merge(currentData, responseData)This avoids clearing form fields that the server does not return.
Error response
data is not changed
message/errors are updatedThis means if page 2 fails, old items stay visible unless you clear them manually:
onError: () => {
tableModel.push({
items: [],
})
}TypeScript
interface ScannerForm {
name: string | null
hub_id: number | null
enabled: boolean
}
const scannerForm = apix.create<ScannerForm>('/scanners/5', {
default: () => ({
name: null,
hub_id: null,
enabled: true,
}),
})
scannerForm.data.name = 'Dock scanner'Collections:
interface Option {
id: number
name: string
}
const options = apix.createCollection<Option>('/options/hubs')
options.data.forEach((option) => {
console.log(option.name)
})Recommended Defaults
For edit forms:
apix.create('/resource/1', {
default: () => ({ /* fields */ }),
})For create forms:
const form = apix.form({
name: '',
email: '',
})
await form.post('/resource')For selectors:
apix.createCollection('/resource/options')For uploads:
apix.createForm('/resource/upload')For table filters:
apix.create('/resource/table', {
only: ['name', 'pagination.page', 'pagination.onPage'],
watch: {
only: ['pagination.page', 'pagination.onPage'],
},
})Notes
- The package is built around JSON data. Dirty/reset tracking is for JSON-like objects and arrays.
- Files and
FormDataare not tracked in snapshots. onlyandomitfilter request payload, not server response.response.onlyandresponse.omitfilter successful response data.- Error response bodies update error state, not
model.data. model.datais always the place for UI binding.- Use
model.stop()when creating models outside Vue component scope.
