Give every selected record a result state
Use a stable record ID and a result state that survives the aggregate banner:
{
"case-a": { state: "succeeded", attempt: 1, message: "" },
"case-b": { state: "failed", attempt: 1, message: "Version changed", retryable: false },
"case-c": { state: "failed", attempt: 2, message: "Temporary error", retryable: true }
}
Useful states are pending, succeeded, failed, and retrying. Do not
replace a settled row with a late duplicate result. React's array-state
guidance applies here: make
an immutable replacement for the one result row rather than mutating a shared
array.
Give each attempt its own request identity. When a retry starts, increment or replace that attempt token; accept a result only when its token matches the currently pending attempt. A stable record ID alone cannot distinguish a late result from the original action and a result from the retry.
Derive the banner from the rows
An aggregate is a summary, never the only source of truth. Derive counts from the per-record states and keep failure rows visible beneath it.
| Aggregate | What the screen must still show |
|---|---|
| All succeeded | Which records completed |
| Some failed | Each failed record, its reason, and its next action |
| Some pending | Which records still have uncertain outcomes |
| Retry in progress | The one record being retried and preserved prior successes |
W3C's status-message guidance is a useful review route for announcing a changed result without a surprising focus move. This draft has not been tested in a browser or with assistive technology; make that product-specific review part of the publication gate.
Retry only under a server-defined rule
React's useOptimistic
can show an immediate pending projection, but it cannot determine whether a
failed server action is safe to repeat. A failure might mean a transient read
problem, a version conflict, an authorization change, or an outcome that is
still unknown. Retrying all selected records can duplicate a consequential
action.
The local P96 model allows retry only for a row marked retryable by the result contract. It verifies that a successful row stays successful during a retry, that a delayed initial-attempt result cannot settle a retry, and that a non-retryable failure does not replay.
Preserve the trusted boundary
The screen must not claim an action was authorized, idempotent, durable, or reversible merely because it rendered a green row. The backend owns those properties and should return enough outcome information for the product's recovery policy. Stop before shipping if a partial failure can be hidden by a success banner or a retry can repeat an action without a server-defined rule.