Forms and inputs
Connect every kind of input to state with v-model, use its modifiers, and send a form without a page reload.
What you'll learn
When you finish, you can do these.- 1Keep a text field and a ref in step with v-model
- 2Connect checkboxes, radios and a select to refs
- 3Send a form without reloading the page, and only once
- 4Spot a field that gives text instead of a number
Picture the order page of a small pizza shop. A customer types a name and an age, picks a size, ticks toppings, chooses a tip and presses Order. Your code needs each answer as the right kind of value: the tip as a number it can add to the price, not as text. Without Vue, you would write code to read every field when Order is pressed, and more to fill every field when your data changes.
You need one idea from the Reactivity basics lesson: a ref, a box that holds a value and tells Vue when it changes. v-model, a Vue attribute for form fields, ties a field to a ref in both directions. The Code tab holds the order form as one component, the file App.vue, with a panel under it that prints every ref. By the end you can read every line of it and add a field of your own.
v-model connects an input to a ref
v-model connects a field to a ref: Vue fills the field from the ref, and every time the person types, it writes the new text back into the ref. In App.vue, the first two fields, Name and Age, each get a ref of their own:
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
const age = ref(30)
</script>
<template>
<label>Name <input v-model.trim="name" /></label>
<label>Age <input v-model="age" type="number" /></label>
</template>
The Name box starts empty and the Age box shows 30. Each field sits inside a <label>, the tag that names it, so a click on the name focuses the field and screen readers announce it.
The .trim on Name is a modifier: a word after a dot that changes what reaches the ref. .trim removes spaces at both ends, so " Asha " arrives as "Asha".
On each keystroke the browser fires an input event. Vue reads the field, applies the modifiers and writes the ref, and the page re-renders: Vue draws it again.
- Keystroke
- input eventchange with .lazy
- .trim / .number
- ref updated
- page re-renders
Two more modifiers change that path. .lazy waits for the change event, fired when the person leaves the field or presses Enter. .number turns the text into a number with parseFloat() and keeps the text when it cannot: "12ab" becomes 12, "abc" stays "abc". A type="number" field, like Age, is converted for you.
The panel under the form, a <pre> tag, prints every ref. Text, called a string in JavaScript, appears in quotes and numbers do not: before anyone types, it shows "name": "" and "age": 30.
Checkboxes, radios, selects and textareas
The other fields use v-model too, each giving its ref a different kind of value:
- A single checkbox gives
trueorfalse, a boolean. - Checkboxes that share one array, a list, add their
valuewhen ticked and remove it when unticked. - Radio buttons that share one ref set it to the chosen one's
value. - A
<select>, the drop-down list, gives the chosen option'svalue. - A
<textarea>, the box for longer text, works like a text input.
In App.vue, the Toppings checkboxes share the array toppings, created as ref<string[]>([]): an empty list of strings. A <fieldset> and its <legend> draw a frame and a title around them:
<fieldset>
<legend>Toppings</legend>
<label><input v-model="toppings" type="checkbox" value="cheese" /> Cheese</label>
<label><input v-model="toppings" type="checkbox" value="olives" /> Olives</label>
</fieldset>
Tick both, and the panel lists "cheese" and "olives" under toppings.
The two Tip radios share one ref, tip, which starts at 0:
<fieldset>
<legend>Tip</legend>
<label><input v-model="tip" type="radio" :value="0" /> None</label>
<label><input v-model="tip" type="radio" :value="2" /> 2 euros</label>
</fieldset>
Under the form, Total: {{ 12 + tip }} euros adds the tip to a price of 12 euros. Pick 2 euros, and it reads 14.
The colon in :value makes that work: it turns the quoted part into JavaScript, so :value="2" gives the number 2. A plain value="2" is always a string, so tip would be "2" and the total "122", 12 and the text "2" stuck together. The colon also lets an option carry an object, as in :value="{ id: 2 }".
Sending the form
To send the order without reloading the page, listen for the form's submit event with @submit, the way @click listens for a click. It fires on a press of the Order button, and on Enter in a text field.
The .prevent modifier calls preventDefault() for you: without it the browser reloads the page, as it does for any plain HTML form.
In App.vue, the form calls the function submit, and :disabled switches the Order button off:
<form @submit.prevent="submit">
<button type="submit" :disabled="!agree || sending">{{ sending ? 'Sending' : 'Order' }}</button>
</form>
The button stays off until the "I agree to the terms" box, bound to agree, is ticked. It is off again, and reads Sending, while sending is true, so a double click cannot send twice. While it is off, Enter does not send either.
submit stands in for a request to a server. It sets sending to true. One second later, setTimeout, JavaScript's timer, sets it back to false and sent, another ref, to true:
<script setup lang="ts">
function submit() {
sending.value = true
setTimeout(() => {
sending.value = false
sent.value = true
}, 1000)
}
</script>
One second after you press Order, a thank-you line such as "Thanks, Asha. Your order is in." appears under the total. The page never reloads.
What goes wrong
Three form mistakes show no error. The panel under the form gives each one away.
The first is braces inside a textarea. The starter's Notes box is a textarea bound to notes. With braces between its tags instead of v-model, typing never reaches notes, and the panel keeps the old text.
<textarea>{{ notes }}</textarea>
<textarea v-model="notes"></textarea>
Text between the tags is only starting content. v-model on the tag sends every keystroke to notes.
The second is maths on a cleared number field. Add a line that prints next year's age, age + 1, and it reads "Next year you turn 31." Clear the Age field, and it reads "Next year you turn 1." The panel shows "age": "": an empty number field gives the empty string, and '' + 1 is the string "1".
<p>Next year you turn {{ age + 1 }}.</p>
<p>Next year you turn {{ typeof age === 'number' ? age + 1 : '?' }}.</p>
typeof age gives the kind of value age holds, so the line adds 1 only to a number and prints a question mark otherwise.
The third is a select whose ref matches none of its options: size holds "xl", and the select looks empty, as if nothing were chosen. On an iPhone, Safari then fires no change event for the first option, so it cannot be chosen.
<script setup lang="ts">
const size = ref('xl')
</script>
<template>
<select v-model="size">
<option value="small">Small</option>
<option value="large">Large</option>
</select>
</template>
<script setup lang="ts">
const size = ref('')
</script>
<template>
<select v-model="size">
<option disabled value="">Pick a size</option>
<option value="small">Small</option>
<option value="large">Large</option>
</select>
</template>
Start at '' with a disabled "Pick a size" option, as the starter does.
What to remember
v-modelkeeps a field and a ref in step both ways, one ref per field. Modifiers such as.trimchange what reaches the ref.- Text inputs and textareas give strings, a single checkbox a boolean, shared checkboxes an array, and radios and selects the chosen
value. - Send with
@submit.prevent, and disable the button while sending. - A plain
value="2"is a string. Use:valuewhen an option should hold a number or an object. - An empty
type="number"field gives the empty string, so guard any maths. Start a select at''with a disabled placeholder option.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add
const crust = ref('thin')and a Crust fieldset with two radios labelled Thin and Thick,value="thin"andvalue="thick", both withv-model="crust". Addcrustto the object in the<pre>. Click Thick: the panel shows"crust": "thick". - Under the textarea, add
<p>{{ notes.length }} / 200</p>, and addmaxlength="200"to the textarea. Type "no onions": the counter reads 9 / 200. - Add
<p>Next year you turn {{ age + 1 }}.</p>. It reads 31. Clear the Age field: it says "you turn 1". Change the braces to{{ typeof age === 'number' ? age + 1 : '?' }}: an empty field now gives "you turn ?".