← All articles

Forms that survive reality: useForm in depth

Cancel that works twice, saves that don't lie, deletes that can be refused, and soft-deleted rows you can actually restore — dissecting the useForm composable from the Regira entities client.
Forms that survive reality: useForm in depth

Nobody's architecture diagram has a box labeled "the edit form", and yet the edit form is where a front end's correctness is actually decided. Can the user press Cancel twice? Does a failed save navigate away anyway? Does creating a record leave the URL on /vehicles/new, ready to create a duplicate on refresh? What happens to the child rows a user removed but hasn't saved yet?

These are the questions the useForm composable in the @regira/modules entities client answers — a Vue 3 form engine that pairs with a Regira.Entities back end. As with the overview deep dive, the specifics are one library, but every decision in here was paid for by a bug you have probably shipped too.

The contract

useForm({ entityService, props, emit }) takes the entity's HTTP service (your EntityServiceBase subclass — the same one the overview article's store pools), the component's props (modelValue, readonly, isPopup) and its typed emit, and returns the following (imports come from @regira/modules/vue/entities, which also ships FormProps, FormEmits<T> and formDefaults so you don't hand-write the prop/emit declarations):

{
    item: Ref<T>            // the working copy the inputs bind to
    original: Ref<T>        // pristine snapshot for cancel/dirty checks
    feedback: FeedbackOut   // success/pending/fail messages
    handleSubmit(): Promise<void>
    handleCancel(): void
    handleRemove(): Promise<void>   // note: no argument — removes the bound item
    handleRestore(): Promise<void>  // un-archives a soft-deleted row
}

Emits follow a fixed vocabulary — save, remove, restore, cancel, update:modelValue, and changeState with a small enum (Pending, Saved, Removed, Error) that containers use to disable buttons and block navigation while a request is in flight.

Small but telling: the form's handleRemove() takes no arguments — it removes the item the form is bound to — while the overview's handleRemove(item) takes the row. Same name, different arity, and mixing them up is a compile error rather than a runtime surprise. Contracts that make misuse unrepresentable are cheaper than documentation.

Two refs and a discipline: item vs original

The engine keeps two copies of the entity. item is the working copy the inputs mutate; original is a pristine snapshot taken on mount (and re-taken whenever the parent pushes a new modelValue). Every hard problem in form state management reduces to being disciplined about when each is written, and always writing them via deep copy + rehydration:

item.value = entityService.toEntity(deepCopy(saved))

Two operations, both load-bearing. deepCopy guarantees the two refs never share nested objects — without it, editing a child row after save mutates your "pristine" snapshot through the shared reference, and cancel silently stops working. toEntity re-wraps the plain JSON as a real model instance, because the client's models are classes whose $id and $title are prototype getters — spread a model ({ ...item }) and those getters are gone, which is exactly why the service throws a descriptive error rather than building a /undefined URL when it meets such an object.

Cancel, done right. The naive cancel is item.value = original.value — and it works exactly once. The user cancels, edits the restored object again, cancels again… and nothing happens, because the first cancel made item be the original, and the second round of edits corrupted it. The engine's version:

function handleCancel(): void {
    emit("cancel", { canceled: item.value, original: original.value })
    // restore a FRESH copy so `original` stays pristine and
    // Cancel works on every click, not just the first
    item.value = entityService.toEntity(deepCopy(original.value))
}

original is never handed out by reference. It's a source you print copies from, not a buffer you lend.

handleSubmit: the full path of a save

The submit handler is worth reading end to end, because nearly every line encodes a decision.

Readonly is a result, not an exception. The handler starts with a guard that returns false after reporting through feedback, and callers early-return on it. The tempting alternative — throw new Error("readonly") — has a subtle failure mode: in an async function a throw before the first await still produces a rejected promise, and with the scaffold's idiomatic binding @submit.prevent="handleSubmit" nobody awaits that promise. The user gets a console warning about an unhandled rejection and no UI at all.

Insert or update is decided by the sentinel. service.save(item) routes on isNewEntity($id)null, undefined, "", "new" and non-positive numbers all mean unsaved (negative numbers are the temp ids child collections mint client-side). Inserts get one extra courtesy: an unsaved id is omitted from the payload entirely, because the server mints the key. Harmless for int keys, essential for Guid string keys — a class initialized with id = "" would otherwise feed an empty string to the server's Guid? binder and fail every create with an opaque 400.

Success rewrites both refs — from the server's copy.

const { saved, isNew } = await entityService.save(item.value)
emit("save", { saved, isNew })
item.value     = entityService.toEntity(deepCopy(saved))
original.value = entityService.toEntity(deepCopy(saved))

Binding the response rather than keeping the client copy matters because the server is allowed to change things: normalized fields, server-stamped timestamps, computed codes, database defaults. After a save, the pristine snapshot is the server's version — cancel now reverts to what's actually stored.

A created record fixes the URL. If isNew and the form isn't in a popup, the handler replaces the route: /vehicles/new becomes /vehicles/42 via router.replacereplace, not push, so the back button doesn't lead to a ghost "new" page, and a refresh re-loads record 42 instead of offering to create it again.

Failure maps status codes to messages, and deliberately does not re-throw. A 400 renders the server's field-level validation errors; 404 says the item is gone; anything else surfaces the server's message. Then the handler stops. No re-throw. This pairs with a strict emit contract: save is emitted only on success, so a consumer that closes a modal or navigates on @save correctly does nothing on failure. Re-throwing would only add an unhandled-rejection warning from the submit binding on top of feedback that's already on screen. Error handling here has one owner, and it's the feedback channel.

Delete and the 409 that isn't your fault

handleRemove follows the same shape: pending state, service.remove, emit remove only on success. The interesting branch is failure: a delete refused by the database — a foreign key still references the row — comes back from a Regira.Entities API as 409 Conflict with a message, and the handler surfaces the server's reason rather than a generic "delete failed". The user learns "still referenced by 3 interventions", which is actionable, instead of a shrug.

Restore: the other half of soft delete

Here the front end and back end genuinely interlock, and you need both halves to see the design.

On the server, any entity marked IArchivable changes the meaning of DELETE: the row isn't removed, it gets IsArchived = true, and a global query filter hides it from every list, count and include from then on. GET /{id} even 404s it. Recoverable deletion for free — if the client can still reach the row to un-archive it.

That's why the details loader (useDetails) requests archived: included when loading a record into the form — the form is the one surface that must see archived rows, precisely so it can offer Restore. And restore itself is almost anticlimactic:

async function handleRestore(): Promise<void> {
    const restoringItem = entityService.toEntity(deepCopy(item.value)) as T & { isArchived: boolean }
    restoringItem.isArchived = false
    const { saved, isNew } = await entityService.save(restoringItem)
    // ... same success path as submit: emit, rebind item + original
}

No special endpoint. Un-archiving is a plain save with the flag cleared — the write path resolves archived rows server-side. One requirement keeps the loop closed, and it's a modeling decision on the .NET side: isArchived must stay on the entity's input DTO. Trim it away as "server-managed" and restore silently stops working — the flag can no longer travel back.

Owned collections: the _deleted convention

Real forms edit graphs, not rows: an order with lines, a party with addresses. The client-side convention for rows the user removed-but-not-saved is a _deleted marker — the row stays in the array (rendered struck-through, undoable until save) instead of being spliced out. The useListItemInput composable itself just toggles the marker on every row; the library's InputSelectorInline component adds one refinement on top — rows added this session (recognizable by their temp ids) are removed outright on delete, since there's nothing to undo. Either way a removed row never reaches the server — it's gone from the array, or filtered out with the other _deleted rows before the save (next section).

The underscore prefix is not decoration. EntityServiceBase.prepareItem strips every top-level property starting with _ before a save — transient client state never reaches the wire. But the strip deliberately does not recurse, so _deleted child rows are still sent unless you drop them yourself. That's the one override an entity service with owned collections almost always carries:

protected override prepareItem(item: Order): Order {
    item.lines = item.lines?.filter((l) => !(l as any)._deleted)
    return super.prepareItem(item)
}

Why does filtering them out delete them? Because of how the server syncs owned collections: a Regira.Entities parent registered with e.Related(x => x.Lines) diffs the incoming collection against the stored one — rows present are kept or updated, rows absent are removed. Which surfaces the sharpest edge in the whole stack, worth engraving somewhere visible:

On the parent's input DTO, null and [] are opposites. A null collection — unsent, or left off the input DTO entirely — means "not my save — touch nothing". An empty array means "sync to empty" — delete every child row. A client that "tidies" absent collections into empty arrays is issuing mass deletes.

One practical consequence: the library's own collection editor normalizes an unset modelValue to [] when it mounts. That's correct for a form that really edits the collection (the rows were eager-loaded into it), but it means you shouldn't mount a collection editor over data you didn't load — an empty editor over an unloaded collection is the tidy-into-[] mistake with a UI on top.

The form layer's conventions — _deleted markers, temp ids, the prepareItem filter — are all downstream of that server-side contract. When you understand the diff, the client conventions stop being folklore.

What to steal

Five decisions from this composable that transfer to any form implementation, in any framework:

  1. Two refs, deep-copied, one direction. A working copy and a pristine snapshot; the snapshot is copied from, never lent out. Cancel must work an arbitrary number of times.
  2. Rebind from the response. After a save, the server's version replaces both refs. Client state is a proposal; the response is the fact.
  3. Emit only on success; don't re-throw handled errors. Consumers navigate/close on the success event and need no failure choreography. Errors get exactly one owner.
  4. Fix the URL after create — with replace, not push.
  5. Know your collection semantics. Whatever your back end's convention for absent-vs-empty child collections, the entire form layer must be built around it — it is the difference between "untouched" and "deleted everything".

The overview that leads into this form has its own deep dive — races, URL-driven state, and deletes that can be refused: Anatomy of a list view.

Docs: regira.github.io/Regira-Modules — vue/entities: views, services, abstractions.