Skip to content

Client-Side Forms ​

useForm() gives a page reactive form state from a validation schema: current values, field errors, touched and dirty flags, and a submitting flag. It validates in the browser, submits the form, and applies the server's response, including 422 errors.

Create a form ​

Import useForm() from your framework adapter and pass the shared schema:

tsx
// app/register/page.tsx
'use client'

import { useForm } from '@holo-js/adapter-next/client'
import { registerForm } from '@/lib/schemas/register'

export default function RegisterPage() {
  const form = useForm(registerForm, {
    action: '/api/register',
    validateOn: 'blur',
    initialValues: { email: '', password: '', passwordConfirmation: '' },
  })

  return (
    <form onSubmit={(event) => { event.preventDefault(); void form.submit() }}>
      <input
        name="email"
        type="email"
        value={form.values.email}
        onInput={event => form.fields.email.onInput(event.currentTarget.value)}
        onBlur={() => form.fields.email.onBlur()}
      />
      {form.errors.has('email') ? <p>{form.errors.first('email')}</p> : null}

      <input
        name="password"
        type="password"
        value={form.values.password}
        onInput={event => form.fields.password.onInput(event.currentTarget.value)}
        onBlur={() => form.fields.password.onBlur()}
      />
      {form.errors.has('password') ? <p>{form.errors.first('password')}</p> : null}

      <input
        name="passwordConfirmation"
        type="password"
        value={form.values.passwordConfirmation}
        onInput={event => form.fields.passwordConfirmation.onInput(event.currentTarget.value)}
      />

      <button disabled={form.submitting}>
        {form.submitting ? 'Creating account...' : 'Create account'}
      </button>
    </form>
  )
}
vue
<!-- app/pages/register.vue -->
<script setup lang="ts">
import { useForm } from '@holo-js/adapter-nuxt/client'
import { registerForm } from '#shared/schemas/register'

const form = useForm(registerForm, {
  action: '/api/register',
  validateOn: 'blur',
  initialValues: { email: '', password: '', passwordConfirmation: '' },
})
</script>

<template>
  <form @submit.prevent="form.submit()">
    <input name="email" type="email" v-model="form.values.email" @blur="form.fields.email.onBlur()">
    <p v-if="form.errors.has('email')">{{ form.errors.first('email') }}</p>

    <input name="password" type="password" v-model="form.values.password" @blur="form.fields.password.onBlur()">
    <p v-if="form.errors.has('password')">{{ form.errors.first('password') }}</p>

    <input name="passwordConfirmation" type="password" v-model="form.values.passwordConfirmation">

    <button :disabled="form.submitting">
      {{ form.submitting ? 'Creating account...' : 'Create account' }}
    </button>
  </form>
</template>
svelte
<!-- src/routes/register/+page.svelte -->
<script lang="ts">
  import { useForm } from '@holo-js/adapter-sveltekit/client'
  import { registerForm } from '#lib/schemas/register.js'

  import type { PageData } from './$types'

  export let data: PageData

  const form = useForm(registerForm, {
    pageData: data,
    action: '/api/register',
    validateOn: 'blur',
    initialValues: { email: '', password: '', passwordConfirmation: '' },
  })
</script>

<form on:submit|preventDefault={() => form.submit()}>
  <input
    name="email"
    type="email"
    value={form.values.email}
    on:input={event => form.fields.email.onInput(event.currentTarget.value)}
    on:blur={() => form.fields.email.onBlur()}
  >
  {#if form.errors.has('email')}<p>{form.errors.first('email')}</p>{/if}

  <input
    name="password"
    type="password"
    value={form.values.password}
    on:input={event => form.fields.password.onInput(event.currentTarget.value)}
    on:blur={() => form.fields.password.onBlur()}
  >
  {#if form.errors.has('password')}<p>{form.errors.first('password')}</p>{/if}

  <input
    name="passwordConfirmation"
    type="password"
    value={form.values.passwordConfirmation}
    on:input={event => form.fields.passwordConfirmation.onInput(event.currentTarget.value)}
  >

  <button disabled={form.submitting}>
    {form.submitting ? 'Creating account...' : 'Create account'}
  </button>
</form>

Read values from form.values and errors from form.errors. Use form.fields.<name> to report input and blur events. In Nuxt you can bind v-model to form.values directly.

TIP

The schema runs in the browser as well as on the server. Keep checks that need the database or secrets (like "email already taken") in the server handler, not in a customAsync() rule on the shared schema.

When validation runs ​

Set validateOn to choose when the browser checks fields. The whole form is always validated on submit.

validateOnBehavior
'submit' (default)Only on submit
'blur'When fields.<name>.onBlur() is called. Only that field's errors are updated, so untouched fields stay quiet.
'change'On every fields.<name>.onInput(), set(), or form.setValue()

Submitting ​

form.submit() validates in the browser first. If anything fails, it shows the errors and doesn't send a request.

With no submitter, the built-in one sends the form with fetch:

  • URL: action option, or the <form action> attribute.
  • Method: method option, or the <form method> attribute, or POST.
  • Body: FormData built from the form element and form.values. For GET and HEAD, the values go in the query string instead.
  • CSRF: for unsafe methods, the CSRF token from the security cookie is added automatically as _token when @holo-js/security is installed. A _token field already in the form is kept.

The response is applied to the form:

Server responseResult
JSON { ok: true, status, data }Stored in form.lastSubmission
204, or any 2xx non-JSON responseTreated as success
422 error payloadField errors and values applied
Network failure or non-JSON error_root error: "Unable to submit the form right now. Please try again."

After a failure, password and other sensitive fields are cleared, and form.submitting goes back to false.

Use your own submitter ​

Pass submitter to send the request yourself, for example to call a Next.js server action or use Nuxt's $fetch. It receives a context object and returns the server's response:

Context propertyTypeValue
actionstring or undefinedThe action option, or the <form action> attribute
methodstringThe method option, or the <form method> attribute, or POST
valuesTDataThe values that passed browser validation
formDataFormDataThe form body. For unsafe methods, it already contains the CSRF field when @holo-js/security is installed.
ts
import { useForm } from '@holo-js/adapter-nuxt/client'
import { postForm } from '#shared/schemas/post'

const form = useForm(postForm, {
  initialValues: { title: '', body: '', status: 'draft' },
  async submitter({ formData }) {
    const submission = await $fetch('/api/posts', { method: 'POST', body: formData })

    if (submission?.ok === true) {
      await navigateTo('/posts')
    }

    return submission
  },
})

If the submitter throws a validation error (such as a $fetch error with a 422 body), useForm() applies it like a normal response. The CSRF token is already in formData when the submitter runs.

Frontend and API on different origins ​

When the page and the API live on different origins, the page can't read the API's CSRF configuration cookie. Point csrf.endpoint at an API route that returns { token: string, field?: string }. Return the field name so the client can use your API's configured CSRF input:

ts
const form = useForm(contactForm, {
  action: 'https://api.example.com/api/v1/contact',
  credentials: 'include',
  csrf: {
    endpoint: 'https://api.example.com/api/v1/csrf',
  },
})

Your token route can return both values from csrf.field(request):

ts
import { csrf } from '@holo-js/security'

export async function GET(request: Request) {
  const { name: field, value: token } = await csrf.field(request)
  return Response.json({ token, field })
}

Before each unsafe submission, useForm() fetches the endpoint with the same credentials. If the response includes field, it sends the plain token in that form field. This works with custom API field and header names even when the frontend can't read the API's cookies.

Existing { token } responses keep their behavior: the client sends the token in the frontend's configured csrf.header (X-CSRF-TOKEN by default), or its configured form field when the header is the encrypted-cookie-only X-XSRF-TOKEN. The client doesn't add the cookie's token in endpoint mode. The API must allow the page's origin with credentials; see CORS and Proxies. These options only apply to the built-in submitter.

Field state ​

Each schema field has an entry in form.fields, nested the same way as the schema (form.fields.address.city).

MemberDescription
valueCurrent value (read and write)
errorsMessages for this field and anything nested under it
touchedtrue once the field has been edited or blurred
dirtytrue when the value differs from its initial value
onInput(value)Set the value from an input event
set(value)Set the value programmatically
onBlur()Mark as touched, and validate when validateOn is 'blur'
validate()Validate now and return this field's messages

Form methods and state ​

MemberDescription
valuesAll current values
errorsThe error bag: has(), first(), get(), flatten()
submittingtrue while a submission is in flight
validtrue when there are no errors
lastSubmissionThe last response applied to the form
submit()Validate and submit
validate()Validate every field and return the submission object, with valid, data, values, and errors
validateField(path)Validate and return messages for one path, such as 'address.city'
setValue(path, value)Set a value by path
reset(values?)Go back to the initial values, merged with any values you pass (which become the new initial values). Clears errors, touched state, and lastSubmission.
applyServerState(result)Apply a server response you received some other way
subscribe(listener)Call listener whenever the form changes. Returns a function that stops listening. Framework adapters use it to re-render.
ts
const stop = form.subscribe(() => console.log(form.values))
stop()

useForm options ​

OptionTypeDescription
initialValuesPartial<TData>Starting values
initialStateserialize() or fail() outputServer state to show on first render, such as values and errors from a previous attempt
validateOn'submit' | 'blur' | 'change'When to validate in the browser. Default 'submit'.
actionstringURL for the built-in submitter
methodstringHTTP method for the built-in submitter
credentials'omit' | 'same-origin' | 'include'fetch credentials mode for the built-in submitter
csrf{ endpoint: string }Fetch the CSRF token from this URL before unsafe submissions
submitter(context) => resultReplace the built-in submitter

Rate limiting has no client option. It is always enforced on the server with throttle.

Next steps ​

Released under the MIT License.