Vue / Basic: reactive components and input contracts / Lesson 6
Forms, validation, and stable list identity
Model editable input and repeated controls so values, errors, and local state remain attached to the intended record.
On this page
Lesson goals
- Explain native v-model and validation boundaries.
- Use stable keys and accessible repeated controls.
Treat form input as a draft
v-model coordinates a native control’s value and relevant event, with behavior depending on control type. A text input, checkbox, radio, and select do not all produce the same data shape. Keep draft state separate from a confirmed server record when users can cancel or validation can fail. Choose when errors appear and how a user corrects them. A client check improves feedback but does not replace server-side validation and authorization. Explicit labels, clear instructions, and keyboard submission are part of the form’s behavior rather than optional decoration added after the data flow works.
Validate the proposed value before emitting it
The form owns a text draft and emits a normalized title only when it is nonempty after trimming. The error is initially absent and becomes visible after an invalid submission. Its label is structurally associated with the input by nesting, avoiding an arbitrary shared id in this one-field example. The submit modifier prevents native navigation while the handler decides the result. A successful emit is still a proposal to the parent, not proof that a remote save succeeded. Keep pending, success, and server error states explicit when adding persistence.
<script setup>
import { ref } from "vue"
const draft = ref("")
const error = ref("")
const emit = defineEmits(["save-title"])
function submit() {
const title = draft.value.trim()
error.value = title ? "" : "Enter a title"
if (title) emit("save-title", title)
}
</script>
<template>
<form @submit.prevent="submit">
<label>Note title <input v-model="draft"></label>
<p v-if="error" role="alert">{{ error }}</p>
<button type="submit">Save title</button>
</form>
</template>
In a configured SFC app, submitting spaces displays Enter a title and emits no save event. Submitting a nonempty title emits its trimmed value. Browser focus, error announcement, and event behavior require separate actual UI testing.
Keep conversion and business validity separate
Modifiers such as trim, number, and lazy change the binding contract and should be chosen deliberately. number is not a promise that every state is a valid business number; empty input and unparsable values need explicit handling according to the documented behavior. Use bound values for nonstring select or radio choices when that is the intended model. A custom component can expose v-model through modelValue and update:modelValue, or defineModel in supported versions; defineModel is available in this Vue 3.5 baseline. Avoid an uncoordinated child default that diverges from an undefined parent value.
Bind repeated state to stable ids
A list key tells Vue which rendered item represents which logical record during insertion, filtering, and reordering. Use a stable unique primitive id rather than an array index when item state matters. A key is not an HTML id and does not automatically label a control. The example nests each checkbox inside its label and uses the record id as its key. Reordering keeps the intended record identity. Derive filtered lists before rendering instead of putting v-if and v-for on the same element, whose precedence can make scope confusing in Vue 3.
<script setup>
import { ref } from "vue"
const notes = ref([
{ id: "n1", title: "Reactivity", selected: false },
{ id: "n2", title: "Components", selected: true }
])
</script>
<template>
<ul>
<li v-for="note in notes" :key="note.id">
<label><input type="checkbox" v-model="note.selected"> {{ note.title }}</label>
</li>
</ul>
</template>
The initial Components checkbox is selected and Reactivity is not. The component owns this local array; a prop-backed collection would need the parent-update contract instead of hidden nested mutation. No browser test is claimed.
Test editing, reordering, and error recovery
Verify that input survives the intended lifecycle, errors are associated with the relevant control, and a user can correct and resubmit using keyboard or assistive technology. Test insertion, deletion, and sorting while controls contain local state. Duplicate keys are a contract defect even when the first screenshot looks correct. For larger forms, separate reusable validation rules from presentation and retain server errors without confusing them with client checks. Avoid persisting every keystroke as if it were a confirmed record unless the product explicitly defines that behavior. A form is complete when the user task and data contract agree.
Common errors and fixes
Input state moves to another row after sorting
- Why it happens
- The list uses position as identity.
- How to fix it
- Use stable unique record keys.
- Prevent it next time
- Test stateful rows during reordering.
An empty numeric input is treated as a valid count
- Why it happens
- Binding conversion is confused with business validation.
- How to fix it
- Validate emptiness, type, range, and meaning explicitly.
- Prevent it next time
- Include boundary cases in the form contract.
A child model starts differently from the parent
- Why it happens
- A child default is uncoordinated with an undefined parent value.
- How to fix it
- Initialize ownership deliberately or require the model value.
- Prevent it next time
- Test initial and externally changed values.
Check your understanding
1. Why is an array index risky as a key for editable rows?
Show worked solution
The same position can represent another record after insertion or sorting, allowing local control state to follow the position instead of the record. A stable id preserves the intended logical identity; labels and validation still require their own contracts.
2. A title passes client validation and a server save fails. What state should remain?
Show worked solution
Retain the user’s draft and expose the server failure separately with a deliberate retry path. Do not erase the text or announce persistence merely because a local event was emitted. Verify retry does not create duplicate confirmed operations.
Key takeaways
- A draft and a confirmed record have different lifetimes.
- Binding conversion does not replace validation.
- Stable keys protect logical identity during list changes.