21

Custom components

Connect any Vue input component to a NotForm field by mapping its value and events, and build reusable field wrappers.

Any control can work with NotForm. You only need to answer two questions about it:

  1. How does it take and emit its value? Usually v-model.
  2. Which of its events mean "typed", "committed", and "left"? These map to onInput, onChange, and onBlur.

Choose the binding

The control...Bind it like this
Forwards native input, change, and blur eventsv-model="field.model" v-bind="field.events"
Emits only update:modelValue when a value is pickedv-model="field.model" @update:model-value="field.events.onChange"
Emits update:modelValue on every keystrokev-model="field.model" @update:model-value="field.events.onInput"
Emits an event when focus leaves, such as blur, focusout, or close@blur="field.events.onBlur"

Use the event names from the component's own documentation.

Which handler means what

  • onInput: the value changed while the user is still editing, such as typing. By default it only validates if the field already shows an error.
  • onChange: the user committed a value, such as selecting an option. It validates by default.
  • onBlur: focus left the control. It marks the field touched and validates by default.

Pick the handler that matches the interaction. You can change what each one triggers with validateOn and validationMode.

Keep v-model before the event binding. The value has to be written before onChange or onInput takes its snapshot for validation.

Controls that emit a value

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('country')"
  >
    <CountryPicker
      v-model="field.model"
      :invalid="!field.isValid"
      @update:model-value="field.events.onChange"
      @focusout="field.events.onBlur"
    />

    <FieldError :errors="field" />
  </Field>
</template>

Controls that forward native events

<template>
  <Field
    v-slot="{ field }"
    :field="form.field('displayName')"
  >
    <TextInput
      v-model="field.model"
      v-bind="field.events"
    />

    <FieldError :errors="field" />
  </Field>
</template>

field.events contains onBlur, onChange, and onInput. If the component does not forward those events, bind the callbacks explicitly, as above.

Controls that do not use v-model

If a control exposes a value prop and a callback instead, write through field.model yourself:

<template>
  <RatingInput
    :value="field.model"
    @rate="(value) => {
      field.model = value
      field.events.onChange()
    }"
  />
</template>

Assign first, then call the event.

A reusable field wrapper

Most apps wrap label, input, and error once. Accept a field instance as a prop.

<script setup lang="ts">
import { FieldError, type FieldInstance } from 'notform'

defineProps<{
  field: FieldInstance<string>
  label: string
  type?: string
}>()
</script>

<template>
  <div class="field">
    <label :for="field.path">{{ label }}</label>

    <input
      :id="field.path"
      v-model="field.model"
      v-bind="field.events"
      :type="type ?? 'text'"
      :aria-invalid="!field.isValid"
    >

    <FieldError
      :errors="field"
      class="field-error"
      role="alert"
    />
  </div>
</template>
<template>
  <AppTextField
    :field="form.field('email')"
    label="Email"
    type="email"
  />
</template>

The parent keeps the form instance and passes only the field. Array items are fields too, so <AppTextField :field="item.field('email')" /> works inside ArrayField.

Checklist for a new control

  • Does field.model reflect the control's value type? Make sure the schema accepts that type (for example boolean, string[], or a number).
  • Does the control have an element that can take focus, so onBlur can fire?
  • Does it have an accessible name? Use field.path for ids.
  • Does it show field.isValid and the error message?
Copyright © 2026 NotForm
Published under MIT License