Recipes

Handling rejected writes

An optimistic write the API server refuses snaps back on its own — the engine rebases it away, so you write no rollback code. Your only job is to *surface* it: `onRejected(envelope, reason)` fires with the mutation that failed and why, and you show a toast, reopen the form, or offer a retry.

View as Markdown

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 .args schema (a malformed or tampered call).
  • A throw in the body — a precondition guard (if (!allowed) throw …) rolls the server transaction back.
  • A blanket authorizeMutation gate — 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