Edit Forms That Survive Reality
Cancel, save, delete and restore, done right in Vue
Nobody's architecture diagram has a box labeled "the edit form", and yet the edit form is where a front end's correctness is 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 has not saved yet?
The useForm composable in the @regira/modules entities client — a Vue 3 form engine that pairs with a Regira.Entities back end — has an answer for each. As in the overview deep dive, the specifics are one library, but every answer was paid for by a bug you have probably shipped too.
Universal or Regira? The five decisions this post ends on are plain Vue 3 — two refs, a deep copy, and a discipline about which one is written when. You can implement every one of them with ref() and no dependency. What useForm supplies on top is the wiring to one specific back end: the entity service it saves through, the _deleted convention its owned collections speak, and the archive/restore pair that exists only because the server offers soft delete. Change the back end and the decisions survive; the wiring does not.
The contract
useForm({ entityService, props, emit }) takes the entity's HTTP service (your EntityServiceBase subclass — the one the overview article's store pools), the component's props (modelValue, readonly, isPopup) and its typed emit. Imports come from @regira/modules/vue/entities (npm i @regira/modules@6.5.0). It returns:
{
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. A write ends in exactly one of the last three, and in Error only when its request failed.
Two refs and a discipline: item and 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 comes down to when each is written. item takes the parent's modelValue as it is; every other write, to either ref, is a deep copy + rehydration:
item.value = entityService.toEntity(deepCopy(saved))
Both halves are load-bearing. deepCopy keeps the two refs from sharing nested objects; without it, editing a child row after a save mutates the "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 and those getters are gone.
Cancel, done right. The naive cancel is item.value = original.value, and it works exactly once: the first cancel made item be the original, and the next 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 is a source you print copies from, not a buffer you lend.
Save: the full path of handleSubmit
Nearly every line of the submit handler encodes a decision.
Readonly is a result, not an exception. The handler starts with a guard that reports through feedback and returns; nothing is thrown.
Insert or update is decided by the sentinel. service.save(item) routes on isNewEntity($id), and an insert omits the unsaved id 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 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, so cancel reverts to what is actually stored.
A created record fixes the URL. If isNew and the form is not in a popup, the handler replaces the route: /vehicles/new becomes /vehicles/42 via router.replace — replace, not push, so the back button does not lead to a ghost "new" page, and a refresh reloads record 42 instead of offering to create it again.
Failure maps status codes to messages, and 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. save is emitted only on success, so a consumer that closes a modal or navigates on @save does nothing on failure; re-throwing would only add an unhandled-rejection warning — nobody awaits the promise behind @submit.prevent="handleSubmit" — on top of feedback already on screen. Error handling has one owner, the feedback channel.
Delete, and the 409 that is not your fault
handleRemove follows the same shape: pending state, service.remove, emit remove only on success. The interesting branch is failure. A delete the database refuses — a foreign key still references the row — comes back from a Regira.Entities API as a 409 Conflict ProblemDetails, and the handler shows its detail rather than a bare "delete failed": the user reads Deleting failed: A database constraint rejected the change., and the record stays on screen. The server keeps that text generic because the database's own message can name indexes and other users' values; it stays in the server log. A delete a validator forbids answers a 400 instead, and its messages show like a save's.
Restore: the client half of soft delete
On the server, an entity marked IArchivable changes what DELETE means: the row gets IsArchived = true and a global filter hides it from every list, count and lookup — the rules are in Delete Without Losing Anything. The client's half is two decisions. The details loader (useDetails) requests archived: included when it loads a record into the form, because the form is the one surface that must see archived rows — that is what makes Restore possible at all. 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, which only works if the flag can travel back — a requirement on the API side, in the box below.
Owned collections: the _deleted convention
Real forms edit graphs, not rows. A child row the user removed stays in the array with a _deleted marker, struck through and undoable until the save; how the entity service drops it before sending, and why an absent collection and an empty one mean opposite things to the server, is in Omitted Is Not Empty.
Page, popup, read-only, archived
The same four handlers serve every form you will wire; the props decide the details.
- A page of its own (
/vehicles/new,/vehicles/42): a create ends withrouter.replaceto the new id, and a refresh reloads the record. - A popup (
isPopup): the route is left alone, and the container closes on@save— which, sincesaveis emitted only on success, never happens over a failed save.changeStateis what the container watches to disable its buttons and block navigation in the meantime. - A read-only view (
readonly): the same component; submit, delete and restore report throughfeedbackand return. - An archived record:
useDetailsloads it witharchived: included, so the form can show it and offer Restore.
What your API must provide.
isArchivedon the entity's input DTO — trim it away as "server-managed" and Restore stops working, because the cleared flag can no longer travel back. A details endpoint that honorsarchived: included, so the form can load a soft-deleted row at all. And a409 Conflictwith adetail, or a400with the rule's message, when a delete is refused, so the user reads why instead of "delete failed". A Regira.Entities API does all three; on another back end they are the contract to build.
What to steal
Five decisions from this composable that transfer to any form implementation, in any framework:
- Two refs, deep-copied, one direction. The snapshot is copied from, never lent out.
- Rebind from the response. Client state is a proposal; the response is the fact.
- Emit only on success; do not re-throw handled errors. Errors get one owner.
- Fix the URL after create — with
replace, not push. - Know your collection semantics. The whole form layer is built around what your back end does with an absent versus an empty child collection.
Why not VeeValidate, FormKit or TanStack Form? Because they solve the neighboring problem, and you will probably want one of them as well. VeeValidate and TanStack Form own field state — touched, dirty per field, schema rules, the message under the input; FormKit owns that plus the inputs themselves. None of them own the entity lifecycle this post is about: what Cancel restores, what a save replaces, what a refused delete leaves on screen. useForm has no opinion about validation — it hands you item and stays out of the way, which is the seam a validation library binds to. The like-for-like comparison is the one nobody ships as a package: your own deep copy of modelValue plus a submit handler, where the five decisions above get rediscovered one bug at a time.
The overview that leads into this form has its own deep dive — races, URL-driven state, and deletes that can be refused: List Screens That Survive the Back Button.
Docs: regira.github.io/Regira-Modules.
Versions: the imports above are @regira/modules 6.5.0, where a refused write ends changeState in Error alone (through 6.4, Saved or Removed followed it); validators arrived in Regira Entities 6.5.0; handleSubmit stopped re-throwing on failure in 6.1.2.