useLukkForm
Most of what a Laravel app does is submit a form and show the validation errors it gets back. useLukkForm is the client for exactly that: hold the fields, submit them to your API, and bind a Laravel 422 bag onto per-field errors — over useLukkFetch, so it is authenticated, SSR-correct, and identical in either transport mode. It is modelled closely on Inertia's useForm, so it should feel familiar.
useLukkForm
const form = useLukkForm(initialData, options?)useLukkForm returns a single reactive object. The fields live under form.data (not spread onto the form itself), so a field may safely be named errors, processing, or submit. Every useLukkForm() call is an independent form.
const form = useLukkForm({ email: '', password: '' })
form.data // { email, password } — the live, editable fields
form.errors // { email?: string, password?: string } — first message per field
form.processing // boolean — a submit is in flight
form.hasErrors // boolean
form.isDirty // boolean — data differs from the defaults
form.wasSuccessful // boolean — the last submit succeeded
form.recentlySuccessful // boolean — true briefly after success (for a "Saved!" flash)The optional second argument configures the form:
useLukkForm({ … }, {
recentlySuccessfulMs: 2000, // how long recentlySuccessful stays true
rememberKey: 'signup', // persist form.data across SPA navigation (Nuxt useState)
})With rememberKey, a half-filled form survives a route change and back — mount another form with the same key and its data is restored. (The reset/isDirty baseline isn't remembered; isDirty compares the restored data against the original initial.) Use it for plain drafts: the state is serialized into the SSR payload, so don't remember File/Blob fields or sensitive values.
The value type flows through, so form.data.email is typed, and the returned form is a LukkForm<T>.
Basic Usage
Bind the fields with v-model="form.data.*", submit with a verb, and read form.errors in the template:
<script setup lang="ts">
const form = useLukkForm({ email: '', password: '' })
async function register() {
try {
await form.post('/register') // form.data is sent as the body
await navigateTo('/dashboard')
}
catch {
// The 422 is already bound to form.errors; other failures rethrow to your handler.
}
}
</script>
<template>
<form @submit.prevent="register">
<input v-model="form.data.email" type="email">
<small v-if="form.errors.email">{{ form.errors.email }}</small>
<input v-model="form.data.password" type="password">
<small v-if="form.errors.password">{{ form.errors.password }}</small>
<button :disabled="form.processing">Create account</button>
</form>
</template>That is the whole loop — no manual error plumbing, no Accept headers, no bearer handling.
Validation Errors
When a submit fails with a Laravel 422, lukk maps the bag onto form.errors, keyed by field. Laravel returns an array of messages per field; form.errors surfaces the first one (the same choice Inertia makes):
// Laravel's 422 body
{ "message": "…", "errors": { "email": ["The email has already been taken."] } }form.errors.email // "The email has already been taken."
form.hasErrors // trueEvery submit clears the errors first, then re-populates them only from a 422 — so a stale message never lingers into the next attempt.
NOTE
Nested & array fields. Laravel flattens the bag into dot notation — nested objects as authorization.role, array items as users.0.email. form.errors keys them exactly as returned, so read them with bracket access: form.errors['authorization.role']. For a nested form.data, use form.nestedErrors — the same errors expanded into a nested object: bind form.nestedErrors.authorization?.role, and array fields nest under their index, form.nestedErrors.users?.[0]?.email.
You can also drive errors yourself (e.g. from a client-side check). All of these are chainable (they return the form):
form.setError('email', 'That address looks off.')
form.setError({ email: 'Required.', password: 'Too short.' }) // set several at once
form.clearErrors('email') // clear one (or several) fields
form.clearErrors() // clear allSubmitting
There is a method per verb, plus a generic submit:
form.post('/register')
form.put(`/posts/${id}`)
form.patch(`/users/${id}`)
form.delete(`/posts/${id}`)
form.get('/search') // sends the fields as the query string
form.submit('post', '/register') // the generic formFor every verb except get, form.data is sent as the request body; get sends the (flat) fields as the query string.
Each call returns a promise of the parsed response body, and rejects with a typed LukkError ({ status, message, errors }) on failure — so you can await the result, or try/catch and branch on status:
const user = await form.post<User>('/register') // typed via the generic
try {
await form.put(`/posts/${id}`)
}
catch (e) {
if ((e as LukkError).status === 403) { /* … */ }
}The last argument accepts per-submit ofetch options (headers, signal, …) alongside the lifecycle hooks below:
form.post('/posts', { headers: { 'X-Idempotency-Key': key } })Lifecycle Hooks
Pass onSuccess, onError, and onFinish in the submit options. They run in addition to the returned promise, and mirror Inertia's callbacks:
form.post('/posts', {
onSuccess: (post) => navigateTo(`/posts/${post.id}`),
onError: (error) => console.warn(error.message),
onFinish: () => { /* runs on success AND failure — like `finally` */ },
})onSuccess(result)— the parsed response body.onError(lukkError)— the typed error (also rethrown to yourawait/catch).onFinish()— always runs, even if the request fails oronSuccessthrows.
Form State
Beyond processing and hasErrors, the form tracks the outcome of the last submit — handy for buttons and "Saved!" flashes:
<button :disabled="form.processing">
{{ form.processing ? 'Saving…' : 'Save' }}
</button>
<span v-if="form.recentlySuccessful">Saved ✓</span>processing— a submit is in flight. Reset on every exit path (success or failure).wasSuccessful— the last submit succeeded. Reset at the start of the next submit.recentlySuccessful— flips totrueon success and back tofalseafterrecentlySuccessfulMs(default 2000).isDirty— whetherform.datadiffers from the current defaults (see below).
Defaults & Resetting
A form remembers its defaults — initially the data you passed in. reset restores them, and isDirty compares against them:
form.reset() // restore every field to its default
form.reset('email') // restore only some fields
form.resetAndClearErrors() // reset AND clear the errors in one callYou may re-baseline the defaults with defaults — for example after loading an edit form, so isDirty starts clean and reset returns to the loaded values:
const form = useLukkForm({ title: '', body: '' })
const post = await $lukk('/posts/1') // load current values
form.data.title = post.title
form.data.body = post.body
form.defaults() // baseline := current data → isDirty is false again
form.defaults('title', 'Untitled') // re-baseline a single field
form.defaults({ title: '…', body: '…' })// or severalOn a successful submit, the defaults are re-baselined to the just-submitted data automatically, so isDirty flips back to false after a save. For a create form where you want the fields cleared instead, call reset() in onSuccess:
form.post('/posts', { onSuccess: () => form.reset() }) // clear the form after creatingNOTE
isDirty is a structural (JSON) comparison of data vs. the defaults, so it does not diff File/Blob fields (both serialize to {}) — track those separately if you need to.
Transforming Data
Register a transform to map form.data into the payload sent on every subsequent submit — without mutating the fields the user sees. A classic use is dropping a confirmation field:
const form = useLukkForm({ password: '', password_confirmation: '' })
form.transform((data) => ({ password: data.password })) // send only `password`
await form.post('/password') // body is { password: '…' }File Uploads
Put a File or Blob anywhere in form.data and the submit is automatically sent as multipart/form-data — nested keys are flattened Laravel-style (avatar, tags[0], meta[views]), booleans become '1'/'0', and Dates become ISO strings. The BFF proxy streams the upload.
<script setup lang="ts">
const form = useLukkForm({ title: '', avatar: null as File | null })
function onPick(e: Event) {
form.data.avatar = (e.target as HTMLInputElement).files?.[0] ?? null
}
</script>
<template>
<form @submit.prevent="form.post('/avatar')">
<input v-model="form.data.title">
<input type="file" @change="onPick">
<button :disabled="form.processing">Upload</button>
</form>
</template>Force multipart even without a file with forceFormData: true. For a <input type="file" multiple>, store a File[] (spread the FileList: [...files]) rather than the raw FileList.
NOTE
Files must be sent by a body verb — a File in a get query is stringified and lost. File/Blob fields are passed by reference through reset/isDirty/the success rebase, so large uploads are never byte-copied.
Cancelling a Submit
form.cancel() aborts the most-recent in-flight submit; it then rejects with an AbortError:
const form = useLukkForm({ q: '' })
async function search() {
form.cancel() // drop any previous in-flight search
try {
results.value = await form.get('/search')
}
catch (e) {
if ((e as Error).name !== 'AbortError') throw e
}
}If you pass your own signal in the submit options, that wins and cancel() is a no-op for that submit.
Notes & Caveats
- Transport-agnostic.
useLukkFormworks identically in BFF and direct mode, and in anuseAsyncData/SSR context, because it submits throughuseLukkFetch— which forwards the session on SSR and injects the bearer as the mode requires. - Field values must be plain. Use strings, numbers, booleans, arrays, plain objects,
Date,File/Blob. Don't pass functions, class instances, or reactive proxies as fields —reset/isDirtyclone and compare them structurally. getflattens only top-level fields into the query string. Nested objects don't round-trip as query parameters.- Chaining.
setError,clearErrors,reset,resetAndClearErrors,defaults, andtransformall return the form, so they compose:form.reset().clearErrors().
NOTE
A custom /register (or bespoke login) route lives on the Laravel side. To hand back a lukk session after your own registration flow, issue tokens with startSession(); to accept a different credential field or add checks at login, use custom login logic.
Next: Using lukk-core.