← All articles

Edit Forms That Survive Reality

Cancel, save, delete and restore, done right in Vue

Edit forms where Cancel always works, a save shows what the server stored, a refused delete says so, and a deleted record can be brought back. Four decisions, in Vue.
Edit Forms That Survive Reality

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.

What each handler does to the two refs: item takes modelValue as it is and original a copy of it on mount; Cancel copies original into item afresh; Save and Restore send item through entityService.save() and, on success, copy the server's saved entity into both refs; Delete goes through entityService.remove(); every failure, 400, 404 or the 409 with the server's reason, ends in feedback.fail() with no save or remove emit and neither ref written.

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 with router.replace to the new id, and a refresh reloads the record.
  • A popup (isPopup): the route is left alone, and the container closes on @save — which, since save is emitted only on success, never happens over a failed save. changeState is 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 through feedback and return.
  • An archived record: useDetails loads it with archived: included, so the form can show it and offer Restore.

What your API must provide. isArchived on 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 honors archived: included, so the form can load a soft-deleted row at all. And a 409 Conflict with a detail, or a 400 with 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:

  1. Two refs, deep-copied, one direction. The snapshot is copied from, never lent out.
  2. Rebind from the response. Client state is a proposal; the response is the fact.
  3. Emit only on success; do not re-throw handled errors. Errors get one owner.
  4. Fix the URL after create — with replace, not push.
  5. 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.

Built with

@regira/modules

The composables behind these screens are the Regira front-end modules, talking to a Regira Entities API.

What Regira Entities covers Front-end modules on GitHub Free tier & licensing