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
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
| Phase | Runs When | Purpose |
|---|---|---|
before | Before anything else | Validate preconditions. Throw to abort. |
optimistic | After before passes | Update UI immediately before the API call |
effect | After optimistic | The async work (API call, file write, etc.) |
commit | After effect succeeds | Apply the real result to state |
rollback | After effect fails (if rollback: true) | Undo the optimistic update |
onError | After effect fails | Custom error handling in state |
Run a Transaction
// 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:
import { backoff } from "statemesh-core";
mesh.transaction("checkout.submit", {
retry: {
attempts: 3,
delay: backoff({ base: 1000, max: 30000, jitter: true })
}
});| Option | Default | Description |
|---|---|---|
base | 1000 | Starting delay in ms |
max | 30000 | Maximum delay cap |
factor | 2 | Multiplier per attempt |
jitter | false | Randomize delay to prevent thundering herd |
Total Timeout
Limit wall-clock time across all retry attempts:
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:
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:
mesh.transaction("search.products", searchDefinition, {
concurrency: "takeLatest" // Default
});| Policy | Behavior |
|---|---|
takeLatest | Abort and roll back the previous pending run, start the newest |
block | Reject the new run with STATEMESH_TRANSACTION_BLOCKED |
queue | Run calls one after another in call order |
Cancellation
Transactions support cancellation via AbortSignal:
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:
const status = mesh.getTransactionStatus("cart.checkout");
// { status: "idle" | "pending" | "success" | "error", attempts: number, error?: Error }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
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
- Undo / Redo — State history navigation
- Resources — Cached API reads
- Mutations — API writes with optimistic rollback
