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.
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
- 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. - 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.
- Use the snapshot, not the current order. The snapshot is the only order in which “index 2” means what the validator meant.
- 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.
- 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. - Normalise server pointers first. JSON Pointer, dotted paths and bracket paths all reduce to a path array, following normalizing nested field error paths.
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.
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.