Form Components

A reusable input component: defineModel for the value, useId to link the label, and the native element kept reachable.

Syntaxconst model = defineModel<string>({ required: true })

A form component wraps one native input together with its label and its error message, so every field in an application looks and behaves the same way. The component owns the markup. The page that uses it owns the value.

Two-way binding with defineModel

defineModel() is a compiler macro, stable since Vue 3.4. It returns a ref that stays in step with the parent's v-model. It is shorthand: defineModel<string>() declares a prop named modelValue and an event named update:modelValue, exactly as declaring both by hand does. The second example shows that longhand, which is what you will see in code written for Vue 3.3 and earlier. Pass a name for a second binding: defineModel('title') pairs with v-model:title.

Label, error and the real element

useId(), added in Vue 3.5, returns an id that is unique within the application. Use it for <label for> and for aria-describedby on the error text, so no caller has to invent ids. A named slot called error lets one caller swap the plain message for markup without changing the component.

Attributes you did not declare as props, such as type, autocomplete and placeholder, are applied to the root element by default. Set inheritAttrs: false with defineOptions and bind v-bind="$attrs" on the input instead, so they reach the element the browser acts on. defineExpose then publishes a focus method the form can call after a failed submit.

Example

Example · vue
<!-- src/components/TextField.vue -->
<script setup lang="ts">
import { useId, useTemplateRef } from 'vue'

defineOptions({ inheritAttrs: false })

const props = defineProps<{
  label: string
  error?: string
}>()

const model = defineModel<string>({ required: true })

const fieldId = useId()
const errorId = `${fieldId}-error`
const control = useTemplateRef<HTMLInputElement>('control')

defineExpose({
  focus: () => control.value?.focus()
})
</script>

<template>
  <div class="field">
    <label :for="fieldId">{{ label }}</label>
    <input
      ref="control"
      :id="fieldId"
      v-model="model"
      :aria-invalid="props.error ? 'true' : undefined"
      :aria-describedby="props.error ? errorId : undefined"
      v-bind="$attrs"
    />
    <p v-if="props.error" :id="errorId" class="error">
      <slot name="error" :message="props.error">{{ props.error }}</slot>
    </p>
  </div>
</template>

<style scoped>
.field {
  display: grid;
  gap: 0.25rem;
}
.error {
  margin: 0;
  color: #b00020;
}
</style>

When to use it

  • Give every text field in a signup form the same label, hint and error layout.
  • Keep type, autocomplete and placeholder on the real input instead of a wrapper element.
  • Let a parent call focus() on the first invalid field after a failed submit.
  • Change where error text comes from without editing any input markup.

More examples

The longhand defineModel replaces

This is the same component contract as `defineModel<string>()`: a `modelValue` prop plus an `update:modelValue` event. The macro writes this for you. Read the long form so you can recognise it in code written before Vue 3.4, and reach for it when you need the raw event rather than a ref, for example to emit only after a value passes a check.

Example · vue
<!-- src/components/PlainTextField.vue -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

function onInput(event: Event) {
  emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>

<template>
  <input :value="props.modelValue" @input="onInput" />
</template>

<style scoped>
input {
  width: 100%;
}
</style>

Using the field from a form

The caller writes `v-model` and a label, and passes `type` and `autocomplete` as plain attributes. Those two are not declared props, so they fall through to the input through `v-bind="$attrs"`. Autocomplete is worth passing on every real field: it is what makes a password manager fill the form.

Example · vue
<!-- src/components/SignupFields.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TextField from './TextField.vue'

const email = ref('')
const password = ref('')
const errors = ref<{ email?: string; password?: string }>({})
</script>

<template>
  <text-field
    v-model="email"
    label="Email"
    type="email"
    autocomplete="email"
    :error="errors.email"
  />
  <text-field
    v-model="password"
    label="Password"
    type="password"
    autocomplete="new-password"
    :error="errors.password"
  />
</template>

<style scoped>
:deep(.field) {
  margin-bottom: 1rem;
}
</style>

Handling a v-model modifier in the child

`v-model.trim` is built in for native inputs, but a component has to implement its own modifiers. Destructuring `defineModel` gives the ref and a record of the modifiers the parent used, and the `set` transformer runs on every write. The parent writes `<trimmed-field v-model.trim="name" />` and never sees the difference.

Example · vue
<!-- src/components/TrimmedField.vue -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'trim'>({
  required: true,
  set(value: string) {
    return modifiers.trim ? value.trim() : value
  }
})
</script>

<template>
  <input v-model="model" />
</template>

<style scoped>
input {
  padding: 0.4rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.