Show people their own edit before the server agrees.
| Item | Owner | State | Actions |
|---|---|---|---|
| Berth 12 survey | Ada | Held | |
| Hull lines fairing | Grace | Held | |
| Batten inventory | Katherine | Held | |
| Chalk order, Q3 | Margaret | Held | |
| Floor regrid, aft bay | Radia | Held | |
| Offsets book audit | Annie | Held |
All rows held.
Why it feels right
- The edit paints before the server answers because waiting to show someone their own action is a lie about who is in charge.
- Pending is a state, not an animation. The row says Pending in magenta — the running color — and nothing shimmers, because shimmer decorates uncertainty instead of naming it.
- Rejection restores the committed value and says Returned in buff. The flash happens once, on the state change, and never under reduced motion — motion only where meaning changes.
- The reducer is three transitions and owns every rule; the component just schedules the server's answer. Pure logic first is what makes the optimistic path testable at all.
Source
export type RowState = {
id: string;
label: string;
owner: string;
committed: string;
pending?: boolean;
rejected?: boolean;
};
export type RowAction =
| { type: "edit"; id: string; owner: string }
| { type: "settle"; id: string }
| { type: "reject"; id: string };
/* The whole study in three transitions: an edit paints instantly and
remembers what the server last confirmed; settle promotes the paint
to truth; reject restores the confirmed value and says so. */
export function optimisticReducer(
rows: RowState[],
action: RowAction,
): RowState[] {
return rows.map((row) => {
if (row.id !== action.id) return row;
switch (action.type) {
case "edit":
return {
...row,
owner: action.owner,
pending: true,
rejected: undefined,
};
case "settle":
return {
...row,
committed: row.owner,
pending: undefined,
rejected: undefined,
};
case "reject":
return {
...row,
owner: row.committed,
pending: undefined,
rejected: true,
};
}
});
}