Skip to content

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 ​

ts
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.

IMPORTANT

URLs resolve against your app's API base, not lukk's auth base — api.target in direct mode, the proxy mount in bff mode. form.post('/register') reaches your /register, not lukk's POST /auth/register.

That's the intended split: useLukkForm and useLukkFetch are for endpoints you own; lukk's own auth endpoints are reached through the composables (useLukkAuth, useLukkChangePassword, and friends), which hold the right base. To show a lukk 422 on your fields, catch the LukkError and read its errors bag — see Change Password for the pattern.

ts
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:

ts
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 draft is stored under the Nuxt state key lukk:form:<rememberKey>, so it can't collide with the rest of your app's state (lukk-nuxt 0.12.0). Earlier releases used rememberKey itself as the key, which meant rememberKey: 'lukk:user' overwrote the signed-in user. A draft remembered under the old key isn't carried over.

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:

vue
<script setup lang="ts">
const form = useLukkForm({ email: '', password: '' })

async function register() {
  try {
    await form.post('/register')     // YOUR route — see the note above
    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 validation bag — a 422, or any other rejection whose body carries an errors object — lukk maps it 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):

jsonc
// Laravel's 422 body
{ "message": "…", "errors": { "email": ["The email has already been taken."] } }
ts
form.errors.email   // "The email has already been taken."
form.hasErrors      // true

Every submit clears the errors first, then re-populates them only from the rejection's errors bag (whatever its status) — so a stale message never lingers into the next attempt. A field with no message, and a field named __proto__ or constructor, is skipped.

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.

Both are typed (lukk-nuxt 0.12.0). form.errors takes a field of data or any dotted path (`${string}.${string}`), so form.errors['authorization.role'] type-checks, and setError / clearErrors accept the same keys. form.nestedErrors follows the shape of data: an object field nests, an array field is indexed, and a leaf — a scalar, a Date, a File/Blob — is a string. A nested field's own message (Laravel can report items as well as items.0.name) is in form.errors.items, not in nestedErrors. Earlier releases typed errors with the top-level fields only and nestedErrors as Record<string, unknown>, so both of the reads above failed to type-check.

You can also drive errors yourself (e.g. from a client-side check). All of these are chainable (they return the form):

ts
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 all

Submitting ​

There is a method per verb, plus a generic submit:

ts
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 form

For 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 on an HTTP error rejects with a typed LukkError ({ status, message, errors?, reason?, retryAfter? }) — so you can await the result, or try/catch and branch on status. Two rejections are not LukkErrors: a cancelled submit rejects with the AbortError itself, and a network failure with ofetch's FetchError.

ts
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:

ts
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:

ts
form.post<Post>('/posts', {
  onSuccess: (post) => navigateTo(`/posts/${post.id}`), // `post` is a Post
  onError:   (error) => console.warn(error.message),
  onFinish:  () => { /* runs on success AND failure — like `finally` */ },
})
  • onSuccess(result) — the parsed response body, typed by the verb's generic (form.post<Post>(…)); without one it is unknown.
  • onError(error) — what the submit rejects with (also rethrown to your await/catch): typed as a LukkError, though a cancelled or network-failed submit hands it the AbortError or FetchError instead.
  • onFinish() — always runs: on success and on failure, even when onSuccess or onError throws (lukk-nuxt 0.12.0 for onError).

Form State ​

Beyond processing and hasErrors, the form tracks the outcome of the last submit — handy for buttons and "Saved!" flashes:

vue
<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 to true on success and back to false after recentlySuccessfulMs (default 2000).
  • isDirty — whether form.data differs 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:

ts
form.reset()             // restore every field to its default
form.reset('email')      // restore only some fields
form.resetAndClearErrors() // reset AND clear the errors in one call

You 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:

ts
const form = useLukkForm({ title: '', body: '' })

const post = await useLukkFetch()<{ title: string, body: string }>('/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 several

On 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:

ts
form.post('/posts', { onSuccess: () => form.reset() }) // clear the form after creating

NOTE

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:

ts
const form = useLukkForm({ password: '', password_confirmation: '' })

form.transform((data) => ({ password: data.password })) // send only `password`

await form.post('/password') // YOUR route — not lukk's POST /auth/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.

vue
<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 the AbortError itself — a DOMException named AbortError — rather than the FetchError ofetch wraps it in (lukk-nuxt 0.12.0; before, error.name === 'AbortError' never matched). onError and onFinish still run:

ts
const form = useLukkForm({ q: '' })

async function search() {
  form.cancel()                 // optional: a new submit aborts the previous one anyway
  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; aborting your signal (without a reason of your own) rejects with the AbortError the same way.

A new submit supersedes the one in flight (lukk-nuxt 0.12.0), as Inertia's does: the earlier submit is aborted and rejects with the AbortError. One that settles anyway — it ran under a signal of your own, which a new submit can't abort — still resolves or rejects its own promise and runs its own hooks, but leaves processing, errors, wasSuccessful / recentlySuccessful and the defaults to the newer submit. Before, a late 422 from the first submit overwrote the second's success, and processing went false while the second was still out.

Notes & Caveats ​

  • Transport-agnostic. useLukkForm works identically in BFF and direct mode, and in an useAsyncData/SSR context, because it submits through useLukkFetch — 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/isDirty clone and compare them structurally.
  • get flattens only top-level fields into the query string. Nested objects don't round-trip as query parameters.
  • Chaining. setError, clearErrors, reset, resetAndClearErrors, defaults, and transform all 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.