A Form, End to End

Bind every field, submit without a page reload, disable the button while the request is in flight, then clear the form.

Syntax<form @submit.prevent="submit"> … <button type="submit" :disabled="pending">

A working form is four decisions: where each field's value lives, what happens on submit, what the user sees while the submit is running, and what the form looks like afterwards.

Bind, then submit

Every field gets a v-model, so the component's refs are the only copy of the data that matters. The submit handler goes on the <form>, not the button, with @submit.prevent="submit". The .prevent modifier calls event.preventDefault() for you, which is what stops the browser navigating away. Listening on the form means Enter in a text field submits too, which you get for free and would otherwise have to wire up.

In flight

Keep a pending ref. Set it before the request, clear it in a finally block so a thrown error cannot leave the form stuck, and bind it to the button with :disabled="pending". A disabled button is the fix for the double submit, and the button's label is a good place to say what is happening. An error ref holds the message when the request fails.

Afterwards

Clear the form by writing the initial values back to the refs, and only after the request has succeeded. Reset before the await and a failed request loses what the user typed.

One detail that bites: a <button> inside a form defaults to type="submit". An unlabelled Cancel button submits the form.

Example

Example · vue
<!-- SignupForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const email = ref('')
const plan = ref('free')
const consent = ref(false)

const pending = ref(false)
const error = ref('')
const done = ref(false)

async function submit(): Promise<void> {
  if (pending.value) return
  pending.value = true
  error.value = ''
  done.value = false
  try {
    const response = await fetch('/api/signups', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email.value, plan: plan.value })
    })
    if (!response.ok) throw new Error(`Signup failed (${response.status})`)
    email.value = ''
    plan.value = 'free'
    consent.value = false
    done.value = true
  } catch (cause) {
    error.value = cause instanceof Error ? cause.message : 'Signup failed'
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="email">Email</label>
    <input id="email" v-model.trim="email" type="email" required />

    <label for="plan">Plan</label>
    <select id="plan" v-model="plan">
      <option value="free">Free</option>
      <option value="pro">Pro</option>
    </select>

    <label><input type="checkbox" v-model="consent" /> Email me updates</label>

    <button type="submit" :disabled="pending || !consent">
      {{ pending ? 'Sending' : 'Sign up' }}
    </button>

    <p v-if="error">{{ error }}</p>
    <p v-else-if="done">Thanks. Check your inbox.</p>
  </form>
</template>

<style scoped>
label {
  display: block;
}
</style>

When to use it

  • Post a sign-up form to an API without the page reloading.
  • Stop a double charge when an impatient user clicks Submit twice.
  • Clear the fields after a save succeeds but keep them after a failure.
  • Show the error returned by the server next to the form that caused it.

More examples

One object for the fields, and a reset that works

One reactive object instead of six refs, which also makes the whole payload a single JSON.stringify. The important line is Object.assign(form, blank()): writing form = blank() would point the variable at a plain object and the template would stop updating. Note the Cancel-style button carries type="button" so it does not submit.

Example · vue
<!-- ProfileForm.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'

type Profile = { name: string; city: string }

const blank = (): Profile => ({ name: '', city: '' })

const form = reactive<Profile>(blank())
const pending = ref(false)

async function submit(): Promise<void> {
  pending.value = true
  try {
    await fetch('/api/profile', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form)
    })
    // assign onto the same object; replacing it would drop reactivity
    Object.assign(form, blank())
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model.trim="form.name" placeholder="Name" />
    <input v-model.trim="form.city" placeholder="City" />
    <button type="submit" :disabled="pending">Save</button>
    <button type="button" :disabled="pending" @click="Object.assign(form, blank())">
      Reset
    </button>
  </form>
</template>

Checking the fields before the request goes out

A plain function in its own file, so it can be printed and later tested without mounting anything. The submit handler calls it first, stores the result in a ref and returns early when a key is present. Keeping validation out of the component is what makes it reusable across the forms you add next.

Example · ts
// src/lib/validate-signup.ts
export type SignupErrors = { email?: string; plan?: string }

export function validateSignup(email: string, plan: string): SignupErrors {
  const errors: SignupErrors = {}
  if (email === '') errors.email = 'Email is required'
  else if (!email.includes('@')) errors.email = 'Email looks wrong'
  if (plan !== 'free' && plan !== 'pro') errors.plan = 'Pick a plan'
  return errors
}

console.log(validateSignup('', 'free').email)
// Output: 'Email is required'
console.log(validateSignup('[email protected]', 'free').email)
// Output: undefined
console.log(Object.keys(validateSignup('[email protected]', 'gold')).length)
// Output: 1

The submit handler without .prevent

The same form with the modifier removed and the preventDefault written by hand. Both are correct; the modifier is shorter and keeps the handler about the submission. Write it out when the handler decides for itself whether to block the default, such as letting a real navigation happen in one branch.

Example · vue
<!-- FeedbackForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const message = ref('')
const pending = ref(false)

async function submit(event: Event): Promise<void> {
  // what @submit.prevent does for you
  event.preventDefault()
  pending.value = true
  try {
    await fetch('/api/feedback', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: message.value })
    })
    message.value = ''
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <form @submit="submit">
    <textarea v-model.trim="message" rows="4"></textarea>
    <button type="submit" :disabled="pending || message === ''">Send</button>
  </form>
</template>

Discussion

  • Be the first to comment on this lesson.