An optimistic write applies to your local view instantly, before the server has seen it. Usually the server agrees and the write commits. Sometimes it refuses — a validation fails, the caller isn’t allowed, a precondition no longer holds. When that happens the engine rebases the write away: it rewinds the pending mutation and the view snaps back to authoritative state. You never write rollback code. What you do write is the part the engine can’t: telling the user.
How a write gets rejected
A mutator is rejected when its authoritative run can’t complete:
- A failed arg parse — the wire args don’t satisfy the mutator’s
.argsschema (a malformed or tampered call). - A
throwin the body — a precondition guard (if (!allowed) throw …) rolls the server transaction back. - A blanket
authorizeMutationgate — no valid session, so the mutation never runs. - A server-only guard — an ownership or entitlement check the client couldn’t make.
All of these land the same way on the client: the optimistic prediction is discarded on the next rebase,
and onRejected fires.
Surface it with onRejected
Pass onRejected to createRindleClient. It receives the mutation envelope ({ clientID, mid, name, args }) and the reason string from the server:
const app = await createRindleClient({
schema, mutators,
onRejected: (envelope, reason) => {
// the view has ALREADY snapped back — this is purely how you tell the user
showToast(`Couldn't ${envelope.name}: ${reason}`);
},
});
Because the rollback is automatic, onRejected is where UX lives, not correctness. Common moves: a toast
with the reason; reopening the form the user submitted (their input is in envelope.args); or offering
a one-tap retry.
Reject vs. accepted-but-no-op
Not every “nothing happened” is a rejection. A mutator can legitimately do nothing — a body that
no-ops for a non-owner, identically on both tiers — and that is accepted: it’s not an error, onRejected
doesn’t fire, and there’s nothing to snap back because the prediction was already a no-op. Reserve a
throw (a hard reject) for a write that should visibly fail; use a silent no-op for one that should
simply have no effect. (The full model — hard reject vs. accepted-but-no-op — is in
the API server.)
In the wild — Strut
Strut’s authority refuses writes with a throw. Making a deck
private, for instance, checks ownership and a Pro entitlement — either failing throws a 403, which
rolls back the server transaction and snaps the optimistic change back:
// server/rindle-api.ts
const owns = await tx.query(q.deck.where.id(a.id).where(deck.owner_id(user)).one())
if (owns == null) throw new RindleApiError('forbidden', 'not permitted to edit this deck', 403)
if (a.visibility === 'private') {
const ent = await getEntitlements(user)
if (!ent.canKeepPrivate)
throw new RindleApiError('forbidden', 'Private decks are a Pro feature. Upgrade to keep this deck private.', 403)
}
server/rindle-api.ts L226–255 · Strut
The browser client just registers a handler for the snap-back — here it logs; a production app would toast:
// src/rindle/client.ts
onRejected: (envelope, reason) =>
console.error(`[rindle] ${envelope.name} rejected:`, reason),
src/rindle/client.ts L101–102 · Strut
The create-rindle starter ships a runnable version of the full loop: a postMessage
mutator whose server run rejects a body containing “spam,” an onRejected handler wired to a <Toaster>,
and a composer where you can watch the optimistic message appear and then snap back with a toast — the
whole rejection path end to end.
See also
- Isomorphic mutators —
throwto hard-reject; normalize so prediction == commit. - The API server — the two rejection shapes and server-only guards.
- Authorizing reads & writes — the guards that produce most rejections.
- The browser client — how the rebase discards a rejected write.