Lesson 12 covered v-model. This is the rest of a real form: validating it, submitting
it, and — the part tutorials skip — showing the errors the server sends back.
One object for the form
const form = ref({
title: "",
slug: "",
description: "",
status: "DRAFT",
scheduledFor: null,
creatorId: "",
tags: [],
collectionIds: [],
video: { url: null, posterUrl: null, durationSeconds: 0, width: 0, height: 0, sizeBytes: 0 },
});One ref holding the whole form, rather than a ref per field. It matches the shape the API expects,
so submitting is { ...form.value } instead of assembling an object by hand, and resetting
is one assignment.
Binding is then v-model="form.title", v-model="form.status" and so on —
including onto custom components, which is where <TagInput v-model="form.tags" />
and <MediaUpload v-model="form.video" /> come in.
Submitting
<form @submit.prevent="save">.prevent stops the browser's native submit, which would reload the page. Use a real
<form> with a submit handler rather than a click handler on the button
— it gives you Enter-to-submit, native required-field semantics and correct screen-reader behaviour for
free.
async function save() {
if (!validate()) {
toast.error("Fix the highlighted fields.");
return;
}
saving.value = true;
try {
const payload = {
...form.value,
scheduledFor: form.value.scheduledFor ? new Date(form.value.scheduledFor).toISOString() : null,
};
const saved = isNew.value
? await api.createReel(payload)
: await api.updateReel(route.params.id, payload);
toast.success(isNew.value ? "Reel created." : "Changes saved.");
if (isNew.value) router.push({ name: "admin-reel-edit", params: { id: saved.id } });
else original.value = saved;
} catch (e) {
toast.error(e.message);
} finally {
saving.value = false;
}
}Four things there are worth copying.
Validate first and bail. No request goes out for a form that cannot succeed.
saving is set before and cleared in finally. The
finally is what makes a failed save leave the button usable — set it only in the success
path and one API error leaves the form permanently disabled.
The payload is built, not sent raw. scheduledFor is converted to an ISO
string here, because that is what the API wants and the datetime input gives something else.
Errors become a toast, not a crash.
And in the template, the flag disables the button:
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? "Saving…" : "Save" }}
</button>That single :disabled is what prevents a double submit. Without it, an impatient
double-click creates two reels.
Validation
An errors object keyed by field name, and a function that fills it:
const errors = ref({});
function validate() {
const e = {};
if (!form.value.title.trim()) e.title = "A title is required.";
if (!/^[a-z0-9-]+$/.test(form.value.slug)) e.slug = "Lowercase letters, numbers and hyphens only.";
if (form.value.status === "SCHEDULED" && !form.value.scheduledFor) {
e.scheduledFor = "Pick a date, or change the status.";
}
errors.value = e;
return Object.keys(e).length === 0;
}The template reads it per field. With Bootstrap that is two bindings:
id="title"
v-model="form.title"
class="form-control"
:class="{ 'is-invalid': errors.title }"
maxlength="140"
placeholder="Fadeaway over two defenders with 1.2 left"
/>:class="{ 'is-invalid': errors.title }" turns the field red, and
.invalid-feedback is only displayed by Bootstrap when a sibling has
.is-invalid — so the message appears and disappears with the class.
Validate on submit, not on every keystroke
Turning a field red while someone is typing their second character is hostile. Validate on submit, and then — once a field has an error — clear it as they fix it. That is one watcher, and it is worth the ten lines.
Server errors
Client-side validation is a convenience. It is not a guarantee, it can be bypassed with devtools, and it cannot know things only the server knows — that a slug is already taken, that a creator was deleted a moment ago.
The demo application's error class carries field-level errors for exactly this:
export class ApiError extends Error {
constructor(status, message, fieldErrors = []) {
super(message);
this.status = status;
this.fieldErrors = fieldErrors;
}
}The backend's exception handler returns { message, subErrors[] }, and
subErrors names the field. Merging those into the same errors object the
client-side rules use means the display code does not care where an error came from:
catch (e) {
// A 400 with field errors renders next to the fields; anything else is a toast.
if (e.fieldErrors?.length) {
errors.value = Object.fromEntries(e.fieldErrors.map((f) => [f.field, f.message]));
}
toast.error(e.message);
}This is the part most form tutorials leave out, and it is the part that makes a form usable. A server rejection that surfaces as a generic red banner leaves the user hunting for which field was wrong.
Derived fields
The reel editor generates a slug from the title, and then stops:
// True once the user edits the slug by hand, after which the title stops
// driving it - otherwise renaming a published reel would silently change its URL.
const slugTouched = ref(false);That comment is the whole design: once the user edits the slug by hand the title stops driving it, because renaming a published reel would otherwise silently change its URL.
The general pattern — a derived field that becomes independent the moment it is touched — comes up
constantly, and a touched flag is the simplest thing that works. Note this cannot be a
computed: a computed cannot be edited, and the field has to be.
Libraries
For a form of this size, hand-rolled validation is about thirty lines and has no dependencies. For larger forms — cross-field rules, arrays of sub-forms, a schema shared with the backend — VeeValidate and FormKit are the usual answers, and both pair with Zod or Yup for the schema.
Learn the manual version first. The libraries all assume you understand the value-and-event mechanics underneath, and half their documentation is about how to plug into it.
Next: Fetching Data from an API.