Define the mutation effect first

For each endpoint, record whether it can affect:

  • only the returned record;
  • fields calculated by the server;
  • whether the record belongs in the current filtered list;
  • its sort position;
  • totals, counts, or other aggregates;
  • the current user's permissions;
  • related records; or
  • values changed concurrently by another actor.

Also record whether the success response contains the complete canonical record, its version, and every effect needed by the current view.

The tested policy in this article is:

function chooseSyncPolicy(effect) {
  const broadEffect = effect.affectsMembership
    || effect.affectsOrdering
    || effect.affectsAggregates
    || effect.affectsPermissions
    || effect.affectsOtherRecords;

  return broadEffect || !effect.responseComplete
    ? "refetch"
    : "reconcile";
}

That policy is deliberately conservative. A more capable API can return an authoritative list patch or aggregate update, but that becomes a larger documented contract rather than an assumption in the component.

Reconcile a complete returned record

Suppose PATCH /records/a returns the complete saved record:

{
  "id": "a",
  "title": "Alpha revised",
  "active": true,
  "version": 2
}

If the update cannot affect membership, order, aggregates, permissions, or other records, replace the matching object immutably:

setRecords(current => current.map(record =>
  record.id === saved.id ? saved : record
));

React's array-state guidance recommends treating arrays in state as read-only and returning a new array. Its state-structure guidance also cautions against duplicate state that can disagree. Keep one canonical record per ID in the relevant cache or state owner.

Reconciliation avoids a second request and can display server normalization immediately. It is safe only when the response is the authoritative saved object and the endpoint contract covers the view's effects.

Refetch when the view is broader than the response

Refetch the affected query when a mutation can:

  • move a record into or out of the current filter;
  • change its sort position;
  • alter counts or totals;
  • update permissions or related records;
  • trigger server-side automation; or
  • return only an ID, version, or partial object.

With a cache library, the shape may be an explicit invalidation after success:

await updateRecord(input);
await queryClient.invalidateQueries({ queryKey: ["records", filters] });

TanStack Query's mutation invalidation guide is one concrete implementation. The policy does not require that library; a component-owned fetch function can implement the same state transition.

Invalidate every affected query key, not merely the detail view that initiated the mutation. A record change may affect the active list, a count, and a supervisor queue.

Keep optimistic state temporary

Optimistic state answers “what should the interface show while the action is pending?” It does not decide the final synchronization policy.

React's useOptimistic documentation describes optimistic values as temporary while an Action is in progress. After success, converge on the authoritative returned record or a refetched snapshot. After failure, return to the prior canonical value and show the error.

Do not let the optimistic object become a second durable copy that later disagrees with the server-state owner.

Reject stale writes at the server

Send the version the user edited. The server should reject the mutation if the record has changed since that version was read.

An HTTP API can use a strong entity tag with If-Match, or an explicit version field under a documented contract. RFC 9110 describes conditional requests as a way to prevent lost updates. The local fixture uses a simple integer version and returns a modeled 412 conflict.

On conflict:

  1. preserve the user's attempted change;
  2. display that a newer server value exists;
  3. fetch or show the current record;
  4. let the user compare, retry, or cancel; and
  5. never silently overwrite the newer version.

Refetching after an unconditional stale write cannot recover data the server already overwrote. Concurrency protection belongs at the trusted server boundary.

Ignore late reads that would move state backward

Two refetches can finish out of order. Give each snapshot a server version, query generation, or request sequence appropriate to the API. Apply a result only if it is not older than the visible canonical state.

The tested model ignores a snapshot with listVersion: 2 when the client already displays version 3. A real application might use entity tags, updated timestamps with defined ordering, cache generations, or cancellation. Do not invent an ordering rule from timestamps whose semantics are unclear.

Preserve data when refresh fails

After a successful mutation, the follow-up refetch can still fail. Do not turn a populated list into an empty state. Keep the last known data visible and expose a distinct refresh failure:

status: refresh_failed
message: Changes were saved, but the latest list could not be loaded.
action: Retry refresh

This is different from mutation failure. If the mutation succeeded, retrying it could duplicate a non-idempotent action. Retry the read unless the server contract proves the mutation itself did not complete.

Announce the failure in an appropriate status or alert region, keep the retry button keyboard reachable, and return focus only when doing so helps the user. Do not replace the entire workflow with a transient toast that assistive technology or a user who moved focus may miss.

An illustrative React state shape

const [records, setRecords] = useState(initialRecords);
const [sync, setSync] = useState({
  status: "idle",
  error: null,
  conflict: null,
});

Useful explicit states include mutating, refreshing, synced, refresh_failed, and conflicted. Derive the visible record from the canonical array and selected ID rather than storing another selected-record object.

This adapter is illustrative and was not browser-tested for the evidence packet. The framework-independent policy and server model were tested.

Reproduce the eight cases

Run:

npm test --prefix sites/reactjsx.com/evidence/P73

On Node.js 22.18.0, all eight tests passed. They cover immutable reconciliation, an incomplete response, membership and aggregate effects, another actor's change, stale-write conflict, a late snapshot, visible refetch failure, and recovery.

The fixture is synchronous and in memory. It does not establish real network, database, React rendering, cache-library, authentication, or authorization behavior.

Decision checklist

  • The endpoint documents whether its response is the complete saved record.
  • Every affected list, aggregate, permission, and related record is named.
  • A complete narrow response reconciles immutably by stable ID.
  • Broad or incomplete effects invalidate and refetch all affected queries.
  • Optimistic state converges on canonical server state after the Action.
  • Writes include a version precondition and conflicts remain visible.
  • Older late responses cannot replace newer visible state.
  • Refetch failure preserves prior data and offers an accessible retry.
  • The backend still enforces authorization and transaction integrity.

Frequently asked questions

Is refetching always safer?

No. It can retrieve broader canonical state, but it can still arrive late, fail, or reflect an earlier replica. The read contract and stale-response policy remain important.

Is reconciliation always faster?

It avoids one read request, but speed does not make a partial response complete. Use it when the endpoint returns every authoritative effect the view needs.

Can React Query decide this automatically?

No cache library knows the business effects of an undocumented endpoint. It can execute the chosen reconciliation or invalidation policy once the application defines affected query keys and response completeness.