Tx
Tx is an execution layer that bundles optimistic UI updates and server requests into a single transaction and automatically rolls back on failure.
- Run multiple steps in one go
- If something fails in the middle, execute compensating steps in reverse order to restore the UI
- Handle retries (linear/exponential backoff), global timeouts, ViewTransition, and DevTools events in a consistent way.
When you want optimistic UI but keep ending up in half-rolled-back states
When you want to treat multiple API calls/local model updates as a single atomic unit
- When you want to inspect failure cases end-to-end in DevTools
1. Installation & the simplest usage: startTransaction
First, install Tx. The examples in this document use the Local-First model, so we show them installed together.
@firsttx/txitself has no runtime dependency on any particular state management layer.
It only requiresreactas apeerDependencyfor theuseTxhook.
In this document we choose to use the Local-First model in the examples. (GitHub)
Here is an example of adding an item to the cart as a single transaction.
import { startTransaction } from "@firsttx/tx";
import { CartModel } from "./models/cart";
async function addToCart(item: { id: string; name: string; qty: number }) {
// Start a transaction with ViewTransition enabled (timeout defaults to 30s)
const tx = startTransaction({
transition: true,
// timeout: 30_000, // You can adjust the global timeout (ms) if needed.
});
// Step 1: optimistic update to the local model + compensation
await tx.run(
() =>
CartModel.patch((draft) => {
draft.items.push(item);
}),
{
compensate: () =>
CartModel.patch((draft) => {
draft.items.pop();
}),
},
);
// Step 2: server request (can receive an AbortSignal)
await tx.run((signal) =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(item),
signal, // Will be aborted when the transaction times out.
}),
);
// If all steps succeeded, commit the transaction
await tx.commit();
}
Key points here,
-
startTransaction(options?)- Configures the transaction ID, whether to use ViewTransition, and the global timeout.
-
tx.run(fn, options?)- Executes a step function
fn. - If a step fails, later steps are not run and Tx will roll back only the steps that have already succeeded, in reverse order.
- Executes a step function
-
tx.commit()- Signals that all steps have finished successfully.
- If an error occurs before commit, Tx automatically goes into the rollback path.
- If the transaction is already
committed, callingcommit()again is a no-op (idempotent).
startTransaction(options?)
| Name | Type | Default | Description |
|---|---|---|---|
id | string | auto-generated (UUID) | Transaction ID. Used to correlate events in DevTools. |
transition | boolean | false | Whether to use the ViewTransition API on the rollback path. When set to <code>true</code> and the browser supports <code>document.startViewTransition</code>, rollbacks are wrapped in a smooth transition. |
timeout | number | 30000 | Global timeout (ms) for the whole transaction. All steps share this time budget. When exceeded, a <code>TransactionTimeoutError</code> is thrown. |
2. Transaction lifecycle & states
Internally, the transaction engine roughly has the following states,
pending- No steps have been executed yetrunning- One or more steps are currently being executedcommitted- All steps finished andcommithas been calledrolled-back- A failure occurred and all compensations have completed successfullyfailed- An error occurred even while running compensation (seeCompensationFailedErrorbelow)
Which steps get rolled back
- When a failure occurs, only the steps that have already succeeded are candidates for rollback.
- The failing step’s own
compensateis never called. - Compensations are always executed in “reverse order of successful steps”.
Valid call states
- You may call
run()andcommit()only while the transaction is inpendingorrunning. - If you call them after the transaction has already been
committedor has reachedrolled-back/failed, aTransactionStateErroris thrown.
Global timeout & Abort
-
By default, a 30-second global timeout (
timeout: 30000) is applied to the entire transaction. -
For each step, Tx creates an internal
AbortController, and the step function passed totx.runcan optionally accept anAbortSignalargument. -
When the remaining time is exhausted, that step’s
AbortSignalis aborted,- APIs like
fetch(url, { signal }), abortableSleep(ms, signal)can react immediately to the signal and stop execution.
- APIs like
-
If your step function ignores the
AbortSignal, it may keep running even after the timeout, but Tx will already treat the step as failed due to timeout.
When an error occurs in
run, Tx runs thecompensatefunctions of the successful steps in reverse order.If any of these compensations fail, a
CompensationFailedErroris thrown, and the original error plus all compensation errors are collected into a single error object.Because of this,
compensatefunctions should be designed as idempotent operations with very low failure probability.
3. tx.run and retry
Each step can have its own retry strategy. Internally, retry.ts implements linear and exponential backoff. If the step never succeeds after all attempts, a RetryExhaustedError is thrown.
const tx = startTransaction();
await tx.run(
async (signal) => {
const res = await fetch("/api/order", {
method: "POST",
signal,
});
if (!res.ok) {
throw new Error("Order failed");
}
},
{
// Only this step is retried. Compensation is executed only after the final failure.
retry: {
maxAttempts: 3,
delayMs: 300,
backoff: "exponential", // or "linear"
},
compensate: async () => {
// Optional rollback API call, etc.
await fetch("/api/order/cancel", { method: "POST" });
},
},
);
tx.run(fn, options?)
| Name | Type | Default | Description |
|---|---|---|---|
fn | (signal?: AbortSignal) => Promise<T> | T | - | The function executed for this transaction step. It can optionally accept an <code>AbortSignal</code> as the first argument, which you can forward to APIs like <code>fetch</code> for timeout/cancellation support. If it throws, subsequent steps are not executed. |
options.compensate | () => Promise<void> | void | - | Compensation function that reverts the changes up to this step. On rollback, it is called in reverse order, but only for steps that completed successfully. The failing step’s <code>compensate</code> is not called. |
options.retry.maxAttempts | number | 1 | Maximum number of attempts for this step. The default is 1 (i.e., no retry). |
options.retry.delayMs | number | 100 | Base delay (ms) between retries. The actual delay is accumulated according to the selected backoff strategy. |
options.retry.backoff | "exponential" | "linear" | "exponential" | How the retry delay grows. Exponential: 100 → 200 → 400 → 800ms. Linear: 100 → 200 → 300 → 400ms. |
The library also exports DEFAULT_RETRY_CONFIG and RETRY_PRESETS,
import { DEFAULT_RETRY_CONFIG, RETRY_PRESETS } from "@firsttx/tx";
tx.run(doSomething, {
retry: RETRY_PRESETS.aggressive,
});
4. React hook: useTx
In real apps, you usually don’t call startTransaction + tx.run by hand for every case.
Instead, you can use the useTx hook, which bundles optimistic update + rollback + request into a single API.
import { useTx } from "@firsttx/tx";
import { CartModel } from "./models/cart";
export function AddToCartButton({
item,
}: {
item: { id: string; name: string; qty: number };
}) {
const { mutate, isPending, isError, error } = useTx({
// 1) Optimistic UI update
optimistic: (input) => {
CartModel.patch((draft) => {
draft.items.push(input);
});
// Whatever you return here becomes a snapshot passed to rollback / onSuccess, etc.
// In this example we don't need it, so we just return void (snapshot type: void).
},
// 2) Rollback (can receive both variables and snapshot)
rollback: (input, _snapshot) => {
CartModel.patch((draft) => {
// Simplest case: remove the last added item
draft.items.pop();
});
},
// 3) Actual server request (snapshot is available if needed)
request: (input, _snapshot) =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(input),
}),
// Optional: whether to use ViewTransition (default is false)
transition: true,
// Optional: retry policy
retry: {
maxAttempts: 2,
delayMs: 200,
backoff: "exponential",
},
// Optional: logical cancellation on unmount
cancelOnUnmount: true,
onSuccess: () => {
// e.g. show a toast
console.log("Added to cart");
},
onError: (err) => {
console.error("Failed to add item", err);
},
});
return (
<div className="flex items-center gap-2">
<button
type="button"
disabled={isPending}
onClick={() => mutate(item)}
className="rounded-full bg-foreground px-4 py-2 text-xs font-medium text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPending ? "Adding..." : "Add to cart"}
</button>
{isError && (
<p className="text-xs text-destructive">
{error?.message ?? "Failed to add item"}
</p>
)}
</div>
);
}
useTx is generic over variables (V), result (R), and snapshot (S),
optimistic(variables) => snapshotrollback(variables, snapshot)request(variables, snapshot) => resultonSuccess(result, snapshot)onError(error, snapshot)
useTx(config)
| Name | Type | Default | Description |
|---|---|---|---|
config.optimistic | (variables: V) => S | Promise<S> | - | Optimistic UI step. This is executed first when the transaction starts, and is where you patch Local-First models or React state. The return value <code>S</code> is stored as a snapshot and passed to <code>rollback</code>, <code>request</code>, <code>onSuccess</code>, and <code>onError</code>. If you return nothing, the snapshot type is <code>void</code>. |
config.rollback | (variables: V, snapshot: S) => Promise<void> | void | - | Undo the optimistic changes. Called when the request fails, times out, or exhausts its retries. You can use the snapshot to restore the previous state. |
config.request | (variables: V, snapshot: S) => Promise<R> | - | Performs the actual server request. If it fails, <code>rollback</code> is called. You can use the snapshot to send additional metadata to the server. |
config.transition | boolean | false | Whether to wrap the rollback path in the ViewTransition API for smooth UI transitions. Only applied in browsers that support <code>document.startViewTransition</code>; in others it gracefully degrades. |
config.retry | { maxAttempts?: number; delayMs?: number; backoff?: 'exponential' | 'linear' } | DEFAULT_RETRY_CONFIG | Retry policy for the request step. If omitted, the library uses its default (1 attempt, 100ms, exponential backoff). You can also reuse shared patterns via <code>RETRY_PRESETS</code>. |
config.onSuccess | (result: R, snapshot: S) => void | - | Callback invoked after the whole transaction commits successfully. Receives both the server result and the snapshot. |
config.onError | (error: Error, snapshot: S) => void | - | Called when the transaction fails or when an error occurs during rollback. May receive instances of the <code>TxError</code> hierarchy. |
config.cancelOnUnmount | boolean | false | Whether to automatically call <code>cancel()</code> when the component unmounts. In the current implementation this does <strong>logical cancellation only</strong>: it does not stop in-flight network requests, but prevents subsequent state updates and callbacks for this transaction. |
useTx(config) return value
| Name | Type | Default | Description |
|---|---|---|---|
mutate | (variables: V) => void | - | Starts the transaction. Fire-and-forget: it does not return a Promise. Errors are handled internally and surfaced via state flags (like <code>isError</code>) and <code>onError</code>. |
mutateAsync | (variables: V) => Promise<R> | - | Promise-based version of <code>mutate</code>. Use this when you need <code>await</code> / <code>try-catch</code> in the caller. |
cancel | () => void | - | Logically cancels the current transaction so its result is not reflected in the UI. In-flight network/timer work continues to run, but once it completes, state updates and callbacks are ignored. |
isPending | boolean | - | Whether the transaction is currently in progress (including optimistic, request, and rollback). |
isError | boolean | - | Whether the last transaction ended with an error. |
isSuccess | boolean | - | Whether the last transaction committed successfully. |
error | Error | null | - | The last error object, if any. May be an instance of the <code>TxError</code> hierarchy. |
useTxcreates a new underlyingstartTransactionfor each call, and uses it to group the optimistic, request, and rollback steps.When
cancelOnUnmountistrue, unmounting the component automatically callscancel(), preventing further state updates and callbacks for that transaction. In-flight network requests themselves are not aborted.When
transition: trueand the browser supportsdocument.startViewTransition, the rollback path is wrapped in a ViewTransition, providing a smooth “undo” animation. (Successful commit paths do not use ViewTransition.)
5. Error model & DevTools
Tx uses a shared error hierarchy TxError and several concrete subclasses.
5.1 Error hierarchy
-
TxError(abstract base class)getUserMessage(): string- Message safe to show to end usersgetDebugInfo(): string- Detailed string for logging/diagnosticsisRecoverable(): boolean- Whether retry/“try again” UX makes sense
-
TransactionTimeoutError- Thrown when the transaction is interrupted due to the global timeout (or when there’s effectively no time left).
- Typically caused by transient network/system issues and treated as recoverable (true).
-
RetryExhaustedError- Thrown when a step still fails after the configured
maxAttempts. - Also treated as recoverable (true), as it often indicates temporary network/server instability.
- Thrown when a step still fails after the configured
-
CompensationFailedError- Thrown when one or more
compensatecalls fail during rollback. - Contains the original error plus all compensation errors in an internal array.
- Indicates data inconsistency or a case needing manual intervention, and is treated as non-recoverable (false).
- Thrown when one or more
-
TransactionStateError- Thrown when
run/commitis called on a transaction that is alreadycommittedor is inrolled-back/failed. - Signals a programming error and is treated as non-recoverable (false).
- Thrown when
In the UI layer you can handle them like this,
import { TxError } from "@firsttx/tx";
try {
await mutateAsync(input);
} catch (error) {
if (error instanceof TxError) {
toast(error.getUserMessage());
logger.error(error.getDebugInfo(), {
recoverable: error.isRecoverable(),
});
if (error.isRecoverable()) {
// Show a “Try again” button or similar UX.
}
} else {
// Plain Error
toast("An unknown error occurred.");
}
}
5.2 DevTools events
If window.__FIRSTTX_DEVTOOLS__ exists, Tx emits events describing transaction activity,
window.__FIRSTTX_DEVTOOLS__?.emit({
id: crypto.randomUUID(),
category: "tx",
type: "step.success", // e.g. 'start' | 'step.start' | 'step.success' | ...
timestamp: Date.now(),
priority: 1,
data: { /* transaction/step metadata */ },
});
Typical type values include,
start- Transaction startedstep.start/step.success/step.retry/step.fail- Per-step start/success/retry/failurecommit- Commit completedrollback.start/rollback.success/rollback.fail- Rollback began/completed/failedtimeout- Global timeout occurred
The priority field controls importance in DevTools,
- 0 : Normal flow (
step.success, etc.) - 1 : Key events (
start,commit,step.retry, etc.) - 2 : Failures/rollbacks/timeouts and other critical events
Currently, the attempt field in the step.success payload is recorded based on the configured maxAttempts, not the actual number of attempts performed. This can make retry statistics in DevTools slightly inaccurate. Use the transaction ID together with the timeline for precise analysis.
6. Using Tx with Local-First / Prepaint
Finally, Tx can be used on its own, but it really shines when combined with Local-First + Prepaint,
- Prepaint - Restores the last screen immediately on revisit to avoid blank screens. (GitHub)
- Local-First - Uses IndexedDB as the single source of truth so state survives reloads and offline usage. (GitHub)
- Tx - Sits on top to group optimistic updates into atomic transactions and roll them back cleanly on failure. (GitHub)
These layers are separate packages,
- Prepaint: render/initialization layer
- Local-First: data/synchronization layer
- Tx: execution/transaction layer
You can mix and match as needed.
See /docs/prepaint and /docs/local-first for details on the other layers.