Formularios en un componente
En resumen: v-model en Vue une un campo del formulario con el estado en los dos sentidos, así que el estado es la única fuente para comprobar y enviar el formulario.
Problema
Escrito, pero vacío
El comprador rellena el pedido y pulsa «Hacer el pedido». El servidor responde que el nombre es obligatorio. Y el campo «Nombre» muestra el nombre.
Consola
Escribe un nombre, un correo y una dirección, acepta las condiciones y pulsa «Hacer el pedido». El campo conserva «Maria Ivanova», pero la petición lleva name: ''. El correo y la dirección tienen v-model, y el nombre no. El código envía el estado, y el estado no sabe lo que hay escrito en el campo.
La idea
Una verdad: el estado
En la lección sobre el DOM el formulario guardaba el texto él mismo, y el código lo leía al enviar. En un componente hay un segundo lugar: el estado. Si los dos no están conectados, cada uno dice lo suyo, como en el ejemplo.
La solución es que el campo no tenga verdad propia. Muestra el valor del estado y en cada tecla le dice al estado el texto nuevo. Un campo así se llama controlado (en inglés, controlled input).
<script setup>const name = ref('');</script>
<template> <input id="name" v-model="name" /></template>v-model es una forma corta para los dos sentidos: :value="name" (del estado al campo) y @input="name = $event.target.value" (el camino de vuelta). El evento input llega en cada tecla.
v-model conoce el tipo de campo. Así que en un select y en una casilla escribes lo mismo:
<select v-model="payment">…</select><input type="checkbox" v-model="agree" />En una casilla agree es true o false, y en un select es el value de la opción elegida.
Ahora el estado es la única fuente: lo que hay en él está en el campo, y al revés. El botón se puede desactivar con el estado (!agree), y la petición envía exactamente lo que ve el comprador.
Míralo
Mira el círculo
Una letra recorre todo el camino: campo, evento, estado, nuevo dibujado y otra vez el campo. Después, el mismo campo sin ligadura.
<script setup>const name = ref('');</script>
<template> <form @submit.prevent="submit"> <label for="name">Nombre</label> <input id="name" v-model="name" /> </form></template>Paso a paso, en palabras
- El formulario guarda el nombre en el estado: name = ''. El campo de la página muestra lo que dice el estado, y dice: vacío.
- El comprador pulsa M. El navegador pone la letra en el campo y lanza el evento input.
- v-model escucha input. El texto del campo es ahora «M».
- v-model lo escribe en el ref: name.value = "M". Es el único lugar donde vive el nombre.
- El ref cambió, así que Vue vuelve a ejecutar la plantilla. v-model le da al campo el valor de name: «M».
- El campo recibe su valor del estado. Es la misma letra, pero ahora el estado decide lo que muestra el campo.
- Cada tecla recorre todo el círculo: campo, evento, estado, nuevo dibujado, campo. Una sola fuente de verdad: el estado.
- Ahora el mismo campo sin v-model. El campo funciona, pero nadie lo escucha y nadie le da un valor.
- El comprador escribe «María». Las letras se quedan en el DOM. El estado sigue diciendo: name = ''.
- Al enviar, el código manda el estado: { name: '' }. El servidor responde «El nombre es obligatorio», mientras que el campo muestra «María».
- La regla: cada campo tiene v-model, un valor desde el estado y un evento de vuelta a él. Así el campo y el estado no pueden desajustarse.
El círculo parece un rodeo innecesario: la letra está en el campo también sin él. Pero gracias a él el estado y el campo no pueden diferir.
Pruébalo
Liga los campos
El campo Nombre, el select Pago y la casilla Acepto las condiciones no están ligados al estado. Liga los tres. El botón Hacer el pedido está desactivado hasta que se aceptan las condiciones. La comprobación escribe un nombre, un correo y una dirección, elige Tarjeta en la entrega, marca y desmarca la casilla y después hace el pedido. Espera Pedido n.º 61 recibido. y una petición con el nombre escrito y payment: 'card'.
Consola
Solución
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Pedido</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
async function submit() {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Pedido n.º {{ orderId }} recibido.</p>
<form v-else novalidate @submit.prevent="submit">
<p>En el carrito: Pan blanco × 2, Leche fresca × 1</p>
<label for="name">Nombre</label>
<input id="name" v-model="name" />
<label for="email">Correo electrónico</label>
<input id="email" type="email" v-model="email" />
<label for="address">Dirección</label>
<input id="address" v-model="address" />
<label for="payment">Pago</label>
<select id="payment" v-model="payment">
<option value="cash">Contra reembolso</option>
<option value="card">Tarjeta en la entrega</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Acepto las condiciones
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Hacer el pedido
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Los tres campos reciben v-model: v-model="name", v-model="payment" en el <select> y v-model="agree" en la casilla.
La comprobación detecta :value="name" sin v-model: las letras aparecen, pero name sigue vacío y sale vacío. También detecta :checked="agree": la casilla parece marcada, pero agree sigue en false y el botón no se activa.
Pruébalo
Rellena el hueco
¿Qué va en el hueco para que la casilla quede ligada?
<script setup>
const agree = ref(false);
</script>
<template>
<input type="checkbox" ="agree" />
</template>v-model: en una casilla liga checked y el evento change, y agree recibe true o false.Pruébalo
Encuentra el error
Elige la línea con el error.
:value="name" es solo el sentido del estado al campo. Las letras se quedan en el DOM, name sigue vacío, y en el siguiente dibujado Vue vuelve a poner el campo en ese valor. Necesita v-model="name".La idea
Una comprobación junto al campo
El servidor comprueba cada pedido. Pero el comprador no debería esperar una respuesta para enterarse de que olvidó la dirección. Así que el formulario también comprueba por sí mismo antes de enviar.
- Cuándo. Al pulsar «Hacer el pedido». Si hay errores, no sale ninguna petición.
- Qué. Una función
check()devuelve un objeto con los errores por campo:{ name: 'Escribe tu nombre', email: … }. Un objeto vacío significa «todo bien». Se guarda en el estadoerrors. - Dónde. Junto al campo, no en una lista común arriba. El comprador ve qué campo debe corregir.
- Para todos. Un lector de pantalla no ve que el texto es rojo. Por eso el campo recibe
aria-invalid="true"(el lector dice «no válido»), y el mensaje recibe unidal que el campo apunta conaria-describedby. Cuando el foco llega al campo, el lector lee también el mensaje.
<input id="email" v-model="email" :aria-invalid="!!errors.email" :aria-describedby="errors.email ? 'email-error' : undefined"/><p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>El formulario tiene novalidate. Sin él, el navegador detiene el envío con sus propios globos antes de que llegue tu código, y las dos comprobaciones se estorban.
El servidor comprueba otra vez. La comprobación del navegador es una comodidad para el comprador. No protege la tienda: el código de la página está en manos del comprador, y una petición puede llegar también sin él. Por qué es así lo explica la lección «Entrada no confiable».
Pruébalo
Comprueba antes de enviar
Añade una comprobación antes de enviar. Un nombre vacío da Escribe tu nombre, un correo sin @ da El correo debe contener @, una dirección vacía da Escribe una dirección de entrega. Cada mensaje está junto a su campo, en un elemento con el id name-error, email-error y address-error, y el campo tiene aria-invalid=true y aria-describedby apuntando a él. Con errores no sale ninguna petición. La comprobación acepta las condiciones, hace el pedido con el formulario vacío, después con un correo sin @ y al final con todo correcto.
Consola
Solución
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Pedido</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
const errors = ref({});
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Escribe tu nombre';
if (!email.value.includes('@')) found.email = 'El correo debe contener @';
if (!address.value.trim()) found.address = 'Escribe una dirección de entrega';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Pedido n.º {{ orderId }} recibido.</p>
<form v-else novalidate @submit.prevent="submit">
<p>En el carrito: Pan blanco × 2, Leche fresca × 1</p>
<label for="name">Nombre</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">Correo electrónico</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Dirección</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Pago</label>
<select id="payment" v-model="payment">
<option value="cash">Contra reembolso</option>
<option value="card">Tarjeta en la entrega</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Acepto las condiciones
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Hacer el pedido
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Añade const errors = ref({}) y una función check() que devuelva un objeto con los errores. En submit primero const found = check(); errors.value = found;, y si found tiene claves, un return antes de fetch. Junto a cada campo: :aria-invalid="!!errors.name", :aria-describedby="errors.name ? 'name-error' : undefined" y <p v-if="errors.name" id="name-error">…</p>.
La comprobación detecta un mensaje sin aria-invalid y aria-describedby: en pantalla se ve igual, pero para un lector de pantalla el campo está bien. También detecta mensajes tras los cuales la petición sale de todos modos: entonces, un momento después, aparece también la respuesta del servidor.
La idea
Enviar y la respuesta del servidor
La petición tarda, y el comprador no sabe si su clic llegó. Si vuelve a hacer clic, sale un segundo pedido. Por eso el estado tiene también sending:
- Antes de
fetch,sendingpasa atrue. El botón estádisabled, y a su lado dice «Enviando…». - Después de la respuesta,
sendingpasa afalse, sea lo que sea lo que haya vuelto. - 201: el cuerpo es
{ id }. El formulario se sustituye por «Pedido n.º 61 recibido.» - 400: el cuerpo es
{ errors: { name, email, address, items } }, un mensaje por campo. Van al mismoerrors, así que aparecen en los mismos lugares junto a los campos.itemsno tiene campo: aparece junto a la línea con el carrito.
sending.value = true;const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value, /* … */ items: ITEMS }),});const data = await res.json();sending.value = false;if (res.ok) orderId.value = data.id;else errors.value = data.errors;La comprobación del navegador es más simple que la del servidor: solo busca «@». El correo maria@shop la pasa, y el servidor lo devuelve con «El correo electrónico no es válido». Así que el formulario también debe saber mostrar un error que no conocía.
Más precisamente, para curiosos
Modificadores de v-model. v-model.trim quita los espacios del principio y del final, v-model.number convierte el texto en número (para type="number"), y v-model.lazy actualiza en change (cuando el campo pierde el foco), no en cada tecla.
:value sin evento. El campo muestra name, las letras van al DOM, pero name no cambia. Cuando el componente se dibuja de nuevo por otro motivo (por ejemplo, aparece un mensaje de error), Vue devuelve el campo al valor de name, y lo escrito desaparece.
Un objeto en vez de cinco refs. También puedes tener const form = reactive({ name: '', email: '', … }) con v-model="form.name". Vue vigila las propiedades del objeto, así que v-model funciona en cada una.
v-model en un componente es :modelValue hacia abajo y @update:modelValue hacia arriba, como en la lección sobre elevar el estado. Así se hacen los componentes de campo (<MoneyInput v-model="price" />).
Cuándo comprobar. Al enviar es lo más simple. Una comprobación en cada tecla muestra errores antes de que el comprador termine («el correo no es válido» tras la primera letra). La respuesta habitual: la primera comprobación al enviar o al salir del campo (blur), y después en cada tecla, para que el error desaparezca en cuanto se corrige.
Foco en un error. Tras un envío fallido ayuda mover el foco al primer campo con error. Así el teclado y el lector de pantalla llegan directo al problema.
La comprobación no ve el código. Las tareas de aquí comprueban el comportamiento: qué se muestra, qué está desactivado, qué se envía. No ven si la comprobación está en una función aparte ni cómo se llaman las variables.
Ejercicio
El pedido, hasta el final
Ahora el formulario comprueba en el navegador, pero no muestra que está enviando y mete los errores del servidor en un solo párrafo. Conviértelo en un pedido de verdad: mientras la petición se ejecuta, el botón Hacer el pedido está desactivado y hay un texto Enviando… (con role=status). Ante una respuesta 400, los errores de data.errors van junto a los campos, en los mismos lugares que los tuyos (email-error y aria-invalid en el correo), y el error de items junto a la línea con el carrito. La comprobación escribe el correo maria@shop: pasa tu comprobación, pero el servidor lo devuelve con El correo electrónico no es válido. Después añade .bg y el pedido sale bien: Pedido n.º 61 recibido.
Consola
Solución
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Pedido</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const errors = ref({});
const sending = ref(false);
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Escribe tu nombre';
if (!email.value.includes('@')) found.email = 'El correo debe contener @';
if (!address.value.trim()) found.address = 'Escribe una dirección de entrega';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
sending.value = true;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
sending.value = false;
if (res.ok) orderId.value = data.id;
else errors.value = data.errors;
}
</script>
<template>
<p v-if="orderId" role="status">Pedido n.º {{ orderId }} recibido.</p>
<form v-else novalidate @submit.prevent="submit">
<p>En el carrito: Pan blanco × 2, Leche fresca × 1</p>
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<label for="name">Nombre</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">Correo electrónico</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Dirección</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Pago</label>
<select id="payment" v-model="payment">
<option value="cash">Contra reembolso</option>
<option value="card">Tarjeta en la entrega</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Acepto las condiciones
</label>
<p v-if="sending" role="status">Enviando…</p>
<button type="submit" :disabled="!agree || sending">
Hacer el pedido
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Añade const sending = ref(false). Alrededor de fetch: sending.value = true antes y sending.value = false tras la respuesta. El botón es :disabled="!agree || sending", y <p v-if="sending" role="status">Enviando…</p> está encima. Ante un error, en lugar del párrafo común, errors.value = data.errors: los mensajes del servidor van al mismo errors y aparecen junto a los campos. El párrafo problem ya no hace falta.
La comprobación detecta un botón que sigue activado mientras dice «Enviando…», y un botón que nunca se activa tras la respuesta (un false olvidado).
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: El primer componente · Condiciones: vacío, error, cargando · Estado · Datos de una API en un componente
- Continúa en: Estado compartido · El escaparate de la tienda
- Ejercicios sobre el tema: Efectos, datos y formularios