How I build forms without losing my mind.
Forms are where most frontend apps quietly fall apart. Not dramatically — just slowly, through accumulated workarounds, edge cases, and "we'll fix the validation later" comments that become load-bearing. I've built a lot of forms. Here's the system I landed on, and why.
The problem with doing it manually
The naive approach — a bunch of ref() values, a submit handler,
some v-if error messages — works for a login form. It falls over
when you have 20 fields, conditional validation, cross-field dependencies,
and a designer who keeps adding new input types. I've been there. It's not
fun.
I moved to TanStack Vue Form as the base layer. It handles
field state, touched/dirty tracking, and submit validation. But I don't expose
it directly to every component — that would create tight coupling everywhere.
Instead, I wrap it in a provide/inject pattern so any field in
the tree can bind to the form without knowing anything about its parents.
The Form provider
The root Form.vue creates a TanStack form instance and provides
context to all descendants. Any component that needs the form just calls
inject("formContext"). No prop drilling, no Pinia store —
just Vue's built-in dependency injection.
```
// Form.vue
const form = ref(
useForm({
defaultValues: props.defaultValues,
onSubmit: ({ value }) => emit("submit", value),
})
);
const is_actually_dirty = computed(() =>
hashForCompare(form.value.state.values) !==
hashForCompare(props.defaultValues)
);
provide("formContext", {
id: props.id,
form: form.value,
is_dirty: is_actually_dirty,
});
``` InputParent — the field wrapper
Every input component wraps InputParent.vue, which binds
the field to TanStack's form.Field and runs validation. The key
detail: onChange only validates if the field has already been
touched via onBlur. This stops the form from screaming at
users before they've even tried.
// InputParent.vue (simplified)
const { form } = inject("formContext");
// Bind field to TanStack form.Field
// onBlur runs first, onChange only fires after field is touched
validators: {
onBlur: ({ value }) => runValidators(value, props.validation),
onChange: ({ value }) => isFieldTouched
? runValidators(value, props.validation)
: undefined,
onSubmit: ({ value }) => runValidators(value, props.validation),
}
The actual UI — label, error message, slot layout — lives in a
separate
InputLayout.vue. The layers are split deliberately:
binding logic in InputParent, visual logic in InputLayout.
The validation system
All validators live in a single validations.ts file — 35+ functions,
each returning undefined (valid) or a string (error message).
They compose with validateAll which runs them in order and returns
the first failure:
// validations.ts
export const required = (value: any) =>
!value && value !== 0 ? "This field is required" : undefined;
export const maxLength = (max: number) => (value: string) =>
value?.length > max ? `Max ${max} characters` : undefined;
export const phone = (value: string) =>
value && !/^(\+251|0)[79]\d{8}$/.test(value)
? "Enter a valid Ethiopian phone number"
: undefined;
// Run all validators, return first error
export const validateAll = (value: any, validators: ValidatorFn[]) => {
for (const fn of validators) {
const err = fn(value);
if (err) return err;
}
return undefined;
}; Using them on a field:
<Input
name="phone"
label="Phone number"
:validation="{ required, phone }"
/> Dirty tracking that actually works
TanStack Form's built-in dirty tracking doesn't handle File objects
or nested arrays properly — it compares by reference, not value. So I wrote
a hash function that serializes the entire form state to a string and compares
that instead. This feeds into a route guard that prompts "You have unsaved
changes" before navigation.
function hashForCompare(val: any): string {
if (val instanceof File)
return `File:${val.name}:${val.size}:${val.lastModified}`;
if (val instanceof Date)
return `Date:${val.toISOString()}`;
if (Array.isArray(val))
return `[${val.map(hashForCompare).join(",")}]`;
if (val && typeof val === "object")
return `{${Object.entries(val)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${hashForCompare(v)}`)
.join(",")}}`;
return String(val ?? "");
} The part nobody talks about: payload sanitization
Before form values hit the API, they go through a cleaning pipeline:
-
Strip
fakeIdmarkers from array items (used for stable key tracking in dynamic fields) - Remove empty strings, null, and undefined
- Coerce numeric strings to actual numbers
- Allow specific fields to bypass sanitization via an explicit list
This means the API always gets clean data, and I'm not littering every submit handler with the same 15 lines of cleanup logic. The form handles it. That's the deal.
The fakeId trick for dynamic array fields
Some forms have a variable-length list of inputs — a maintenance form where you add as many parts as needed, each with a name and quantity. Every item in the list has the same field names. That creates a tracking problem: if you delete the first item, everything shifts index, and your validation errors end up pointing at the wrong rows.
The fix is a fakeId — a generated ID attached to each item
when it's created. It never goes to the server. Its only job is to
give each row a stable identity for the lifetime of the form session.
// Each item gets a stable fakeId when added to the list
const items = reactive([
{ fakeId: genId(), name: "", quantity: 0 },
]);
function addItem() {
items.push({ fakeId: genId(), name: "", quantity: 0 });
}
function removeItem(id: string) {
const i = items.findIndex(item => item.fakeId === id);
items.splice(i, 1);
}
// Sync the array back to the form field
watch(items, (val) => {
form.setFieldValue("parts", val.map(i => i.value));
}, { deep: true });
Validation errors are then keyed by fakeId instead of array index.
Delete the first row, and the error for the second row stays exactly where it was —
because we're tracking by identity, not position.
// Errors are keyed by fakeId, not index
const errors = reactive<Record<string, string>>({});
validateArrayItems(items, errors, {
name: { required },
quantity: { required, min: 1 },
});
// Error keys look like: "name_abc123", "quantity_abc123"
// So removing item at index 0 doesn't accidentally clear
// the error that belongs to the item now at index 0 This is the kind of bug that doesn't show up until a user deletes a row mid-form and suddenly gets a validation error on a field they filled in correctly. fakeId makes that impossible.
On submit, the sanitization pipeline strips every fakeId key
from every object in every array before the payload leaves the client.
The API never sees it.
// Before the payload hits the API, fakeId is stripped automatically
// Input:
[
{ fakeId: "abc123", name: "Oil filter", quantity: 2 },
{ fakeId: "def456", name: "Brake pad", quantity: 4 },
]
// What the API receives:
[
{ name: "Oil filter", quantity: 2 },
{ name: "Brake pad", quantity: 4 },
] What I'd change
The provide/inject approach works well but makes TypeScript angry
unless you're careful with generic types. I've lost a non-trivial amount of
time to type errors that were entirely my fault. If I were starting over,
I'd define the context type more strictly upfront instead of reaching for
any and fixing it later — which is, I'll admit, exactly what
I did.