Skip to content

Transactions

Transactions own the full async lifecycle. Every async operation in StateMesh — API calls, form submissions, file writes — goes through a transaction. The lifecycle is automatic: validate, optimistic update, effect, commit, rollback, retry, timeout, and cancellation.

Define a Transaction

ts
const checkout = mesh.transaction("cart.checkout", {
  before(state) {
    if (state.cart.items.length === 0) {
      throw new Error("Cart is empty");
    }
  },
  optimistic(state) {
    state.cart.status = "processing";
    state.cart.error = null;
  },
  async effect(state, payload: { paymentMethodId: string }, ctx) {
    const response = await fetch("/api/checkout", {
      method: "POST",
      signal: ctx.signal,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        items: state.cart.items,
        paymentMethodId: payload.paymentMethodId
      })
    });
    if (!response.ok) throw new Error("Checkout failed");
    return response.json() as Promise<{ id: string; total: number }>;
  },
  commit(state, order) {
    state.order = order;
    state.cart.items = [];
    state.cart.status = "completed";
  },
  rollback: true,
  onError(state, error) {
    state.cart.status = "failed";
    state.cart.error = error.message;
  },
  retry: { attempts: 2, delay: 1000 },
  timeout: 10000
});

Lifecycle

PhaseRuns WhenPurpose
beforeBefore anything elseValidate preconditions. Throw to abort.
optimisticAfter before passesUpdate UI immediately before the API call
effectAfter optimisticThe async work (API call, file write, etc.)
commitAfter effect succeedsApply the real result to state
rollbackAfter effect fails (if rollback: true)Undo the optimistic update
onErrorAfter effect failsCustom error handling in state

Run a Transaction

ts
// From code
const result = await checkout.run({ paymentMethodId: "card_1" });

// From React
function CheckoutButton() {
  const checkoutTx = useMeshTransaction(checkout);

  return (
    <button
      disabled={checkoutTx.pending}
      onClick={() => void checkoutTx.run({ paymentMethodId: "card_1" })}
    >
      {checkoutTx.pending ? "Processing..." : "Pay now"}
    </button>
  );
}

Retry with Exponential Backoff

Use the backoff() helper for exponential retry delays with jitter:

ts
import { backoff } from "statemesh-core";

mesh.transaction("checkout.submit", {
  retry: {
    attempts: 3,
    delay: backoff({ base: 1000, max: 30000, jitter: true })
  }
});
OptionDefaultDescription
base1000Starting delay in ms
max30000Maximum delay cap
factor2Multiplier per attempt
jitterfalseRandomize delay to prevent thundering herd

Total Timeout

Limit wall-clock time across all retry attempts:

ts
mesh.transaction("checkout.submit", {
  retry: {
    attempts: 5,
    delay: backoff(),
    totalTimeout: 30000  // Abort if total time exceeds 30s
  }
});

onRetry Callback

Observe retry attempts for logging or analytics:

ts
mesh.transaction("checkout.submit", {
  retry: {
    attempts: 3,
    delay: backoff(),
    onRetry: (attempt, error, ctx) => {
      console.warn(`Retry ${attempt}/3 for ${ctx.name}:`, error.message);
    }
  }
});

Concurrency Policies

Control what happens when a transaction is called while a previous run is still pending:

ts
mesh.transaction("search.products", searchDefinition, {
  concurrency: "takeLatest"  // Default
});
PolicyBehavior
takeLatestAbort and roll back the previous pending run, start the newest
blockReject the new run with STATEMESH_TRANSACTION_BLOCKED
queueRun calls one after another in call order

Cancellation

Transactions support cancellation via AbortSignal:

ts
const checkout = mesh.transaction("cart.checkout", {
  async effect(state, payload, ctx) {
    // ctx.signal is an AbortSignal
    const response = await fetch("/api/checkout", { signal: ctx.signal });
    return response.json();
  }
});

// Cancel from outside
checkout.cancel();

Transaction Status

Track the current status of a transaction:

ts
const status = mesh.getTransactionStatus("cart.checkout");
// { status: "idle" | "pending" | "success" | "error", attempts: number, error?: Error }
tsx
function CheckoutStatus() {
  const status = useMeshSelector((state) => {
    const tx = state.__transactions?.["cart.checkout"];
    return tx?.status ?? "idle";
  });

  if (status === "pending") return <Spinner />;
  if (status === "error") return <ErrorMessage />;
  return null;
}

Example: User Registration

ts
const register = mesh.transaction("auth.register", {
  before(state, payload: { email: string; password: string }) {
    if (!payload.email.includes("@")) throw new Error("Invalid email");
    if (payload.password.length < 8) throw new Error("Password too short");
  },
  optimistic(state) {
    state.auth.registering = true;
    state.auth.error = null;
  },
  async effect(state, payload, ctx) {
    return api.post<User>("/auth/register", payload, { signal: ctx.signal });
  },
  commit(state, user) {
    state.auth.user = user;
    state.auth.registering = false;
  },
  rollback: true,
  onError(state, error) {
    state.auth.registering = false;
    state.auth.error = error.message;
  },
  retry: { attempts: 1, delay: 1000 }
});

Important Notes

TIP

The effect function receives the state at the time the transaction started (before the optimistic update). This is the "snapshot" state used for retry attempts.

WARNING

If rollback is not set, the optimistic state persists even if the effect fails. Always set rollback: true for user-facing transactions.

TIP

The ctx.signal AbortSignal is aborted when the transaction is cancelled, times out, or a new run supersedes the previous one (with takeLatest). Use it to cancel in-flight fetch requests.

Next Steps

Released under the MIT License.