Every validator you use speaks in indices — Zod reports ["items", 2, "price"], a 422 response says /items/2/price — but the moment the user drags a row or deletes one above, index 2 is a different line item, and the error lands on a price that is perfectly valid.

In the dynamic field arrays model, rows have stable ids and all per-row state is keyed by id. The remaining gap is the boundary: errors arrive from code that knows nothing about ids. This page closes it by translating index paths to ids immediately, using a snapshot of the order at the time the data was validated or sent — not the order at the time the answer arrives.


Context and prerequisites

Errors reach an array from three places, with different timing:

  • Synchronous schema validation — computed from the current values, so the current order is the right one to translate with.
  • Asynchronous validation (a worker, a debounced check) — computed from values captured some milliseconds ago. The user may have reordered since.
  • Server responses — computed from the submitted payload, possibly seconds ago. Autosave and slow networks make “the user reordered while the request was in flight” routine.

In every case the correct translation uses the order that produced the error. That means capturing the order alongside the payload, and carrying it until the answer comes back.

A reorder while a submit is in flight The form sends the payload with rows in order A, B, C and keeps a snapshot of that order. While waiting, the user drags C to the top, so the current order is C, A, B. The server responds that items index 2 price must be positive. Translating with the current order would put the error on B. Translating with the snapshot puts it on C, which is the row that was actually invalid. User Form Server POST items [A, B, C] (snapshot order) drags C to the top: now [C, A, B] 422: items[2].price must be positive current order → error on B (wrong) snapshot order → error on C (right)

The core pattern: translate at the boundary with the producing order

type RowId = string;
type Path = (string | number)[];

export interface IdError { rowId: RowId | null; field: string; message: string } // rowId null = array-level

/**
 * Translate schema/server issues with index paths into id-keyed errors.
 * `arrayKey` is the array's property name ("items"); `orderAtProduction`
 * is the list of row ids in the order the VALIDATED/SENT payload used.
 */
export function translateIssues(
  issues: { path: Path; message: string }[],
  arrayKey: string,
  orderAtProduction: RowId[],
  stillExists: (id: RowId) => boolean,
): { rowErrors: IdError[]; unmatched: { path: Path; message: string }[] } {
  const rowErrors: IdError[] = [];
  const unmatched: { path: Path; message: string }[] = [];
  for (const issue of issues) {
    const [head, index, ...rest] = issue.path;
    if (head !== arrayKey) { unmatched.push(issue); continue; }
    if (typeof index !== "number") {
      // Path is just ["items"]: an array-level issue such as min/max count.
      rowErrors.push({ rowId: null, field: "", message: issue.message });
      continue;
    }
    const rowId = orderAtProduction[index];
    // The row may have been deleted since: its error has nowhere to go and
    // must not be re-attached to whatever row now occupies that index.
    if (!rowId || !stillExists(rowId)) continue;
    rowErrors.push({ rowId, field: rest.join("."), message: issue.message });
  }
  return { rowErrors, unmatched };
}

// JSON Pointer from a server ("/items/2/price") → path array.
export const pointerToPath = (p: string): Path =>
  p.split("/").slice(1).map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"))
    .map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg));
// Usage on submit: capture the order WITH the payload.
async function submit(state: ArrayState<Item>) {
  const order = [...state.order];                 // snapshot
  const payload = order.map((id) => state.rows[id]);
  const res = await fetch("/api/invoices", { method: "POST", body: JSON.stringify({ items: payload }) });
  if (res.status === 422) {
    const body = await res.json() as { errors: { pointer: string; detail: string }[] };
    const issues = body.errors.map((e) => ({ path: pointerToPath(e.pointer), message: e.detail }));
    const { rowErrors } = translateIssues(issues, "items", order, (id) => id in currentState().rows);
    applyRowErrors(rowErrors);
  }
}
declare function currentState(): ArrayState<Item>;
declare function applyRowErrors(e: IdError[]): void;
declare type ArrayState<T> = { order: string[]; rows: Record<string, T> };
declare type Item = { description: string; price: number };

Step-by-step walkthrough

  1. Snapshot the order with every payload. Whether it goes to a schema, a worker or the server, keep [...order] alongside it; it is a few bytes.
  2. Translate as soon as the answer arrives. Convert index paths to row ids before storing anything. No index-keyed error should ever live in state.
  3. Use the snapshot, not the current order. The snapshot is the only order in which “index 2” means what the validator meant.
  4. Drop errors for rows that no longer exist. A deleted row’s error must not be inherited by the row that now occupies its index.
  5. Route array-level issues separately. A path of just ["items"] is a count or aggregate rule; it goes to the group message, as in validating minimum and maximum row counts.
  6. Normalise server pointers first. JSON Pointer, dotted paths and bracket paths all reduce to a path array, following normalizing nested field error paths.
Translating the same issue with different orders If nothing changed during the request, both translations give row C. If the user moved C to the top, the snapshot gives C, which is correct, and the current order gives B, which is wrong. If the user deleted C, the snapshot identifies C, which no longer exists, so the error is dropped; the current order would wrongly attach it to whatever row is at index 2, or to nothing if there are only two rows. During the request Snapshot says Current order says nothing changed C C user moved C to the top C B user deleted C C gone: drop the error whatever is now at index 2

Failure modes and edge cases

1. Translating with the current order

The bug is invisible in testing because testers rarely reorder while a request is in flight. It shows up with autosave on slow connections: errors flicker onto the wrong rows. Always pass the snapshot.

2. Nested arrays

For ["jobs", 1, "duties", 3, "text"], translate level by level: the outer snapshot maps index 1 to a job id, and that job’s own duties snapshot maps index 3 to a duty id. Snapshot every level you send.

3. Filtering before sending

If you drop blank rows before submitting, the server’s indices refer to the filtered list. Build the snapshot from the ids actually sent, after filtering — not from the full order.

4. Errors for a row the user is editing

A server error for a row whose value has changed since it was sent may already be fixed. Stamp errors with the value they judged and hide stale ones, as in merging errors from client, schema and server validators.

5. Library-managed arrays

React Hook Form stores errors by index (errors.items?.[2]?.price) and moves them with move/remove operations on the field array. That keeps them aligned for local operations, but server errors set with setError("items.2.price") after a reorder need the same snapshot translation before you call setError.

The boundary where indices become ids A validator or server reports an error with an index path such as a JSON pointer. The path is normalised into an array. The index is translated to a row id using the order snapshot that was taken when the data was validated or sent. If that row no longer exists the error is dropped. Otherwise the error is stored keyed by row id and field, and from then on it follows the row through every reorder. Issue arrives /items/2/price From Zod, a worker or a 422 body. Normalise path ["items", 2, "price"] One shape for every source. Translate with snapshot snapshot[2] → row C The order that produced the error, not today's order. Store by id errors[C].price = message Deleted rows' errors are dropped here.

Verification checklist


Frequently Asked Questions

Can I ask the server to return row ids instead of indices?

Yes, if you send the client row id with each item, the server can echo it in errors, which removes the translation step. It is a good design for APIs you control. You still need the translation for schemas and third-party APIs, so keep the helper.

What if the server reorders items before validating?

Then its indices refer to its own order and cannot be translated reliably. Ask for errors keyed by an identifier you sent, or have the server validate in the received order. This is worth fixing in the API contract rather than working around.

Does this matter for synchronous validation?

Less, because the order cannot change between validating and receiving the result. Passing the current order as the “snapshot” is correct there. The discipline matters for anything asynchronous, which in practice includes most server validation.


Related

← Dynamic Field Arrays and Repeatable Groups