Skip to content

Form Patterns

15.1.1. v-model Binding

Form input values must be managed using two-way v-model binding with ref. When a form has two or more fields, the state must be grouped into an object ref.

vue
<script setup lang="ts">
import { ref } from 'vue'

interface SignUpForm {
  name: string
  email: string
  password: string
  agreeTerms: boolean
}

const form = ref<SignUpForm>({
  name: '',
  email: '',
  password: '',
  agreeTerms: false,
})
</script>

<template>
  <form>
    <input v-model="form.name" type="text" placeholder="Name" />
    <input v-model="form.email" type="email" placeholder="Email" />
    <input v-model="form.password" type="password" placeholder="Password" />
    <label>
      <input v-model="form.agreeTerms" type="checkbox" />
      Agree to Terms of Service
    </label>
  </form>
</template>
  • Form state types must be declared using an interface.
  • Initial values must be assigned with type-appropriate defaults such as empty strings (''), false, or null.

15.1.2. Form State Management

Asynchronous processing states of a form must be managed using the following four categories.

StateTypePurpose
isLoadingbooleanInitial data loading (e.g., edit forms)
isSubmittingbooleanWhether submission is in progress
isSuccessbooleanWhether submission completed successfully
errorsRecord<string, string>Per-field error messages
typescript
const isLoading = ref(false)
const isSubmitting = ref(false)
const isSuccess = ref(false)
const errors = ref<Record<string, string>>({})
  • isLoading and isSubmitting must not be true simultaneously.
  • errors must use field names as keys to map error messages.

15.1.3. Controlled Component Pattern

In Vue 3.4 and above, defineModel must be used to create custom input components.

vue
<script setup lang="ts">
// src/components/BaseInput.vue
const model = defineModel<string>({ required: true })

defineProps<{
  label: string
  error?: string
}>()
</script>

<template>
  <div class="base-input">
    <label>{{ label }}</label>
    <input v-model="model" />
    <span v-if="error" class="error">{{ error }}</span>
  </div>
</template>

The parent component binds directly using v-model.

vue
<template>
  <BaseInput v-model="form.name" label="Name" :error="errors.name" />
  <BaseInput v-model="form.email" label="Email" :error="errors.email" />
</template>
  • defineModel automatically generates the modelValue prop and the update:modelValue emit.
  • For environments below Vue 3.4, the defineProps + defineEmits combination must be used.

15.1.4. Form Submission Handling

Form submission must use @submit.prevent to prevent the default behavior and handle it with an async function. Duplicate submissions must be prevented.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { createUser } from '@/api/user'

const form = ref({ name: '', email: '' })
const isSubmitting = ref(false)
const errors = ref<Record<string, string>>({})

async function handleSubmit() {
  if (isSubmitting.value) return

  isSubmitting.value = true
  errors.value = {}

  try {
    await createUser(form.value)
    form.value = { name: '', email: '' }
  } catch (e) {
    // See Section 15.3 for error handling
  } finally {
    isSubmitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.name" type="text" />
    <input v-model="form.email" type="email" />
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? 'Processing...' : 'Submit' }}
    </button>
  </form>
</template>
  • @submit.prevent must be applied to all forms.
  • Duplicate submission prevention must combine both the isSubmitting guard and the button's disabled attribute.
  • After successful submission, the form state must be reset to its initial values.

TIENIPIA QUALIFIED STANDARD