A confirmation dialog on every row delete trains users to click “Yes” without reading, and does nothing for the delete they did not mean to make; an undo lets them remove rows at full speed and still recover the one that mattered — with everything they had typed into it.

In the dynamic field arrays model, a removed row moves into a removed state rather than vanishing. This page implements that state: what to keep, how long to keep it, how to restore it into the right position when other rows have changed since, and how to handle focus and announcements so that undo works for keyboard and screen-reader users as well as mouse users.


Context and prerequisites

A faithful undo restores the row as it was:

  • its values — including uncontrolled inputs and attached files,
  • its id — so any state keyed by id (touched flags, expanded panels) lines up again,
  • its errors — if it was invalid before, it is invalid after,
  • its position — relative to its neighbours, not a raw index that other edits may have invalidated.

It must also be findable. A toast in the corner that disappears after four seconds is not an undo for a screen-reader user or someone with a motor impairment. The undo control should stay available long enough to reach, and removal should be announced with the undo offered.

Confirm dialog versus undo A confirmation dialog interrupts every delete, is quickly dismissed out of habit, and protects nothing once confirmed; it also moves focus into a modal and back. An undo keeps deletion instant, protects against mistakes noticed a few seconds later, restores everything the row held, and when announced through a live region with a reachable button it works for keyboard and screen-reader users. "Are you sure?" dialog Interrupts every delete. Confirmed out of habit. No recovery once confirmed. Moves focus into a modal and back. Undo Delete stays instant. Recovers mistakes noticed later. Restores values, errors and position. Announced, with a reachable button.

The core pattern: a removal record anchored to neighbours

type RowId = string;

interface Removal<V> {
  id: RowId;
  value: V;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  // Anchor to the NEIGHBOUR, not the index: other rows may be added,
  // removed or moved before the user clicks Undo.
  after: RowId | null;          // the row that was directly above it (null = first)
  removedAt: number;
}

export interface GroupState<V> {
  order: RowId[];
  rows: Record<RowId, V>;
  errors: Record<RowId, Record<string, string>>;
  touched: Record<RowId, Record<string, boolean>>;
  removals: Removal<V>[];       // most recent last
}

export function remove<V>(s: GroupState<V>, id: RowId): GroupState<V> {
  const i = s.order.indexOf(id);
  if (i === -1) return s;
  const removal: Removal<V> = {
    id, value: s.rows[id], errors: s.errors[id] ?? {}, touched: s.touched[id] ?? {},
    after: i > 0 ? s.order[i - 1] : null, removedAt: Date.now(),
  };
  const { [id]: _v, ...rows } = s.rows;
  const { [id]: _e, ...errors } = s.errors;
  const { [id]: _t, ...touched } = s.touched;
  return { ...s, rows, errors, touched, order: s.order.filter((r) => r !== id), removals: [...s.removals, removal] };
}

export function undo<V>(s: GroupState<V>, id?: RowId): GroupState<V> {
  const r = id ? s.removals.find((x) => x.id === id) : s.removals[s.removals.length - 1];
  if (!r) return s;
  // Re-insert after its old upper neighbour if that row still exists;
  // otherwise fall back to the top, which is where the user will look first.
  const anchor = r.after && s.order.includes(r.after) ? s.order.indexOf(r.after) + 1 : 0;
  const order = [...s.order];
  order.splice(anchor, 0, r.id);
  return {
    ...s, order,
    rows: { ...s.rows, [r.id]: r.value },
    errors: { ...s.errors, [r.id]: r.errors },
    touched: { ...s.touched, [r.id]: r.touched },
    removals: s.removals.filter((x) => x !== r),
  };
}

// Removals are discarded on submit or after the undo window.
export const expire = <V>(s: GroupState<V>, now: number, windowMs = 30_000): GroupState<V> =>
  ({ ...s, removals: s.removals.filter((r) => now - r.removedAt < windowMs) });

The removed row keeps its original id, so restoring it brings back the same identity — any state keyed by that id elsewhere (an expanded accordion, a pending file upload) reconnects automatically.


Step-by-step walkthrough

  1. Move removed rows into a removal record. Keep values, errors, touched flags and the id; take them out of the live maps so no other row can inherit them.
  2. Anchor position to a neighbour. “After row B” survives other edits; “index 2” does not. If the neighbour is gone too, restore at the top of the group.
  3. Move focus on remove. Focus the next row’s legend (made focusable with tabindex="-1"), or the previous row’s if it was the last, or the Add button if the group is now empty. Never leave focus on the removed button, which no longer exists.
  4. Announce the removal with the undo. A polite live region says “Contact 2, Grace Hopper, removed”, and an “Undo remove Grace Hopper” button appears in a fixed place in the group — see throttling live region announcements for keeping several quick removals from flooding the region.
  5. Restore and refocus on undo. Re-insert the row, move focus to its first field or legend, and announce “Grace Hopper restored”.
  6. Expire on submit or after a generous window. Thirty seconds or until the next submit is typical; WCAG’s timing guidance favours letting users extend or turn off short limits, so do not make the window tiny.
Remove and undo with focus and announcements The user presses the Remove button on Grace's row. The group moves Grace into a removal record anchored after Ada, moves focus to Alan's row legend, and the live region announces that Grace Hopper was removed and that undo is available. The user tabs to the Undo button and activates it. The group re-inserts Grace after Ada with her values and errors, moves focus to her first field and the live region announces that she was restored. User Group Live region Remove (Grace) focus → Alan's legend "Grace Hopper removed. Undo available." Undo remove Grace Hopper re-insert after Ada; focus → her name field "Grace Hopper restored."

Failure modes and edge cases

1. Undo only in a vanishing toast

Toasts that auto-dismiss after a few seconds and live outside the form are hard to reach by keyboard in time and may never be announced. Put the undo inside the group, keep it for the whole window, and make it a real button with a specific accessible name.

2. Files in removed rows

If a removed row had an uploaded file, keep the file reference (and any object URL) until the removal expires, then revoke it — as in image previews with object URLs without leaks. Revoking on remove makes undo restore a broken preview.

3. Server-backed rows

If rows are saved immediately (each row is its own record), “remove” should be a soft delete that the undo reverses, or the delete request should wait until the undo window expires. Deleting on the server and re-creating on undo produces a new server id and loses history.

4. Minimum count

Removing below the minimum shows the group’s minimum message immediately; undo must clear it again. Derive the message from the live rows rather than setting it imperatively, as in validating minimum and maximum row counts.

5. Several removals in a row

Keep a stack, and offer undo for each removed row by name (“Undo remove Grace Hopper”, “Undo remove Alan Turing”), not just the last. Users who remove three rows and realise the first was a mistake should not have to undo the other two.

Where focus goes after each action After removing a row that has a following row, focus moves to the following row's legend. After removing the last row when others remain, focus moves to the previous row's legend. After removing the only row, focus moves to the Add button. After undo, focus moves to the restored row's first field. After adding a row, focus moves to the new row's first field. Action Focus moves to remove, a row follows the next row's legend remove the last row the previous row's legend remove the only row the Add button undo the restored row's first field add the new row's first field

Verification checklist


Frequently Asked Questions

Is a confirmation dialog ever the better choice?

When the deletion is immediately irreversible on the server and cannot be deferred — deleting a saved record with side effects — a confirmation naming exactly what will be lost is appropriate. For rows in an unsaved form, undo is almost always better.

How long should the undo window be?

Long enough to notice and reach the button without hurry: at least ten seconds, and many teams keep removals until the next submit. Short timeouts penalise users who navigate slowly, which is the population undo most needs to serve.

Should Ctrl+Z trigger undo?

Inside a text field, Ctrl+Z belongs to the browser’s text undo; do not intercept it there. A group-level shortcut outside text fields is a nice addition for power users, but the visible Undo button is the accessible baseline.


Related

← Dynamic Field Arrays and Repeatable Groups