Atomic transaction engine for optimistic UI that sequences steps with compensation, built-in retry/backoff, and ViewTransition-ready rollbacks.
npm install @firsttx/txSafe optimistic updates with atomic transactions

Execute multi-step operations safely with automatic compensation on failure. Perfect for optimistic updates, API calls, and state synchronization.
The only React library with automatic reverse-order compensation.
- Auto rollback: Declare compensate once, auto-execute in reverse order on failure
- Step-level retry: Exponential/linear backoff per step (not just whole operation)
- ViewTransition: Smooth animations during rollback
- Timeout protection: Prevent hanging transactions with configurable limits
- Type-safe errors: Structured error classes with getUserMessage() and isRecoverable()
``bash`
npm install @firsttx/tx


> Module format: ESM-only. CommonJS users should use import() (dynamic import).
---
`tsx
// ❌ Without transactions: Manual rollback hell
async function addToCart(item) {
setCart((prev) => [...prev, item]); // Optimistic update
try {
await api.post('/cart', item);
} catch (error) {
setCart((prev) => prev.filter((i) => i.id !== item.id)); // Manual rollback
toast.error('Failed to add item');
}
}
// What if you have 5 steps? 10 steps?
// What if step 3 fails but step 2's rollback also fails?
`
---
`tsx
// ✅ With transactions: Automatic rollback
import { startTransaction } from '@firsttx/tx';
async function addToCart(item) {
const tx = startTransaction({ transition: true });
await tx.run(() => setCart((prev) => [...prev, item]), {
compensate: () => setCart((prev) => prev.filter((i) => i.id !== item.id)),
});
await tx.run(() => api.post('/cart', item), { retry: { maxAttempts: 3 } });
await tx.commit();
}
// Automatic rollback on ANY step failure
// ViewTransition support for smooth UI updates
// Built-in retry logic
`
---
`tsx
import { startTransaction } from '@firsttx/tx';
const tx = startTransaction({ transition: true });
// Step 1: Optimistic update with compensation
await tx.run(() => CartModel.patch((draft) => draft.items.push(item)), {
compensate: () => CartModel.patch((draft) => draft.items.pop()),
});
// Step 2: Server request with retry
await tx.run(() => fetch('/api/cart', { method: 'POST', body: JSON.stringify(item) }), {
retry: { maxAttempts: 3, delayMs: 1000 },
});
await tx.commit();
`
`tsx
import { useTx } from '@firsttx/tx';
function AddToCartButton({ item }) {
const { mutate, isPending, isError } = useTx({
optimistic: async (item) => {
await CartModel.patch((draft) => draft.items.push(item));
},
rollback: async (item) => {
await CartModel.patch((draft) => {
draft.items = draft.items.filter((i) => i.id !== item.id);
});
},
request: async (item) => {
return fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item),
}).then((r) => r.json());
},
retry: { maxAttempts: 3 },
onSuccess: () => toast.success('Added to cart'),
onError: (err) => toast.error(err.getUserMessage()),
});
return (
);
}
`
---
If any step fails, all completed steps are rolled back in reverse order.
`tsx
const tx = startTransaction();
await tx.run(() => step1(), { compensate: () => undo1() });
await tx.run(() => step2(), { compensate: () => undo2() });
await tx.run(() => step3(), { compensate: () => undo3() });
// If step3 fails:
// 1. Execute undo3 (if step3 started)
// 2. Execute undo2
// 3. Execute undo1
// 4. Re-throw original error
`
---
Exponential or linear backoff strategies.
`tsx`
await tx.run(() => fetch('/api/data'), {
retry: {
maxAttempts: 3,
delayMs: 100,
backoff: 'exponential',
},
});
Retry Presets
Use pre-configured retry strategies:
`tsx
import { RETRY_PRESETS } from '@firsttx/tx';
const { mutate } = useTx({
optimistic: async () => {
/ ... /
},
rollback: async () => {
/ ... /
},
request: async () => {
/ ... /
},
retry: RETRY_PRESETS.default,
});
const tx = startTransaction();
await tx.run(() => fetch('/api/data'), {
retry: RETRY_PRESETS.aggressive,
});
`
Available Presets:
- RETRY_PRESETS.default - 2 attempts, 500ms delay, exponential backoffRETRY_PRESETS.aggressive
- - 5 attempts, 1000ms delay, exponential backoffRETRY_PRESETS.quick
- - 1 attempt, no delay, linear backoff
---
Smooth visual transitions during rollback.
`tsx
const tx = startTransaction({ transition: true });
// On rollback, changes are wrapped in document.startViewTransition()
// Provides smooth fade/slide animations
`
---
Prevent transactions from hanging indefinitely.
`tsx
const tx = startTransaction({ timeout: 5000 }); // 5 second timeout
await tx.run(slowOperation); // If takes >5s, automatic rollback
`
---
Creates a new transaction.
`typescript`
startTransaction({
id?: string, // Custom ID (default: auto-generated UUID)
transition?: boolean, // Use ViewTransition (default: false)
timeout?: number // Timeout in milliseconds (default: 30000)
});
---
Executes a step with optional compensation and retry.
`typescript`
await tx.run
fn: () => Promise
options?: {
compensate?: () => Promise
retry?: {
maxAttempts?: number,
delayMs?: number,
backoff?: 'exponential' | 'linear'
}
}
);
Parameters
- fn: () => Promise - Function to executeoptions?.compensate: () => Promise
- - Rollback function (executed in reverse order on failure)options?.retry: RetryConfig
- - Retry configurationmaxAttempts?: number
- - Maximum retry attempts (default: 1)delayMs?: number
- - Base delay between retries in milliseconds (default: 100)backoff?: 'exponential' | 'linear'
- - Backoff strategy (default: 'exponential')
Returns Promise - Result of the function
Throws
- RetryExhaustedError - When all retry attempts failTransactionTimeoutError
- - When execution exceeds timeout
---
Finalizes the transaction.
`typescript`
await tx.commit();
After commit
- Transaction becomes immutable
- No more steps can be added
- Cleanup tasks are executed
---
React hook for declarative transaction management.
`typescript`
const { mutate, mutateAsync, isPending, isError, isSuccess, error } = useTx({
optimistic: async (variables: TVariables) => { / ... / },
rollback: async (variables: TVariables) => { / ... / },
request: async (variables: TVariables) => { / ... / },
transition?: boolean,
retry?: RetryConfig,
onSuccess?: (result: TResult, variables: TVariables) => void,
onError?: (error: Error, variables: TVariables) => void
});
Parameters
- config.optimistic: (variables: TVariables) => Promise - Immediate local updateconfig.rollback: (variables: TVariables) => Promise
- - Revert local update on failureconfig.request: (variables: TVariables) => Promise
- - Server requestconfig.transition?: boolean
- - Use ViewTransition (default: true)config.retry?: RetryConfig
- - Retry configurationmaxAttempts?: number
- - Default: 1delayMs?: number
- - Default: 100backoff?: 'exponential' | 'linear'
- - Default: 'exponential'config.onSuccess?: (result: TResult, variables: TVariables) => void
- - Success callbackconfig.onError?: (error: Error, variables: TVariables) => void
- - Error callback
Returns
- mutate: (variables: TVariables) => void - Fire-and-forget executionmutateAsync: (variables: TVariables) => Promise
- - Promise-based executionisPending: boolean
- - Whether transaction is runningisError: boolean
- - Whether transaction failedisSuccess: boolean
- - Whether transaction succeedederror: Error | null
- - Error object if failed
---
`tsx
let didUpdate = false;
await tx.run(
async () => {
if (needsUpdate) {
updateData();
didUpdate = true;
await api.save();
}
},
{
compensate: async () => {
if (didUpdate) revertData();
},
},
);
`
`tsx
const completed: string[] = [];
await tx.run(
async () => {
await step1();
completed.push('step1');
await step2();
completed.push('step2');
await step3();
completed.push('step3');
},
{
compensate: async () => {
for (const step of completed.reverse()) {
await rollback(step);
}
},
},
);
`
`tsx
async function deleteItem(id: string) {
const tx = startTransaction({ transition: true });
const itemToDelete = items.find((i) => i.id === id);
await tx.run(
async () => {
setItems((prev) => prev.filter((i) => i.id !== id));
await api.delete(id);
},
{
compensate: async () => {
setItems((prev) => [...prev, itemToDelete]);
},
},
);
await tx.commit();
}
`
---
FirstTx provides structured error classes with user-friendly messages and debug information.
All transaction errors extend TxError:
`typescript
import { TxError } from '@firsttx/tx';
try {
await tx.commit();
} catch (error) {
if (error instanceof TxError) {
// User-friendly message for UI
alert(error.getUserMessage());
// Detailed info for debugging
console.error(error.getDebugInfo());
// Check if retryable
if (error.isRecoverable()) {
// Offer retry option
} else {
// Suggest page refresh
}
}
}
`
---
Thrown when rollback fails, indicating potential data inconsistency.
`typescript
import { CompensationFailedError } from '@firsttx/tx';
try {
await tx.commit();
} catch (error) {
if (error instanceof CompensationFailedError) {
console.error(${error.failures.length} rollback(s) failed);Completed ${error.completedSteps} steps before failure
console.error();
// User message: "Failed to undo changes. Your data may be in an inconsistent state. Please refresh the page."
alert(error.getUserMessage());
// NOT recoverable - requires page refresh
if (!error.isRecoverable()) {
window.location.reload();
}
}
}
`
Fields
- failures: Error[] - All compensation errors (in reverse order)completedSteps: number
- - Number of steps that were completed
---
Thrown when a step fails after all retry attempts.
`typescript
import { RetryExhaustedError } from '@firsttx/tx';
try {
await tx.run(apiCall, { retry: { maxAttempts: 3 } });
} catch (error) {
if (error instanceof RetryExhaustedError) {
console.log(Step ${error.stepId} failed after ${error.attempts} attempts);
// Inspect all attempt errors
error.errors.forEach((err, i) => {
console.log(Attempt ${i + 1}: ${err.message});
});
// User message: "The operation failed after 3 attempts. Please try again later."
toast.error(error.getUserMessage(), {
action: error.isRecoverable()
? {
label: 'Retry',
onClick: retryOperation,
}
: undefined,
});
}
}
`
Fields
- stepId: string - Step identifier (e.g., "step-0")attempts: number
- - Total number of attempts madeerrors: Error[]
- - All attempt errors (in chronological order)
---
Thrown when transaction exceeds configured timeout.
`typescript
import { TransactionTimeoutError } from '@firsttx/tx';
try {
const tx = startTransaction({ timeout: 5000 });
await tx.run(slowOperation);
await tx.commit();
} catch (error) {
if (error instanceof TransactionTimeoutError) {
console.log(Timeout: ${error.timeoutMs}ms (elapsed: ${error.elapsedMs}ms));
// User message: "The operation took too long (over 5000ms). Please try again."
toast.error(error.getUserMessage());
// Recoverable - can retry with longer timeout
if (error.isRecoverable()) {
const newTx = startTransaction({ timeout: 10000 });
// ... retry with 10s timeout
}
}
}
`
Fields
- timeoutMs: number - Configured timeout valueelapsedMs: number
- - Actual elapsed time before timeout
---
Thrown when attempting invalid operations (e.g., adding steps after commit).
`typescript
import { TransactionStateError } from '@firsttx/tx';
const tx = startTransaction();
await tx.commit();
try {
await tx.run(() => {}); // Invalid!
} catch (error) {
if (error instanceof TransactionStateError) {
console.error(Cannot ${error.attemptedAction} in state '${error.currentState}');Transaction ID: ${error.transactionId}
console.error();
// User message: "This operation is no longer available. The transaction has already completed or failed."
// NOT recoverable - indicates a bug
}
}
`
Fields
- currentState: string - Current transaction status ("committed", "rolled-back", "failed")attemptedAction: string
- - What was attempted ("add step", "commit")transactionId: string
- - Transaction identifier
---
`typescript
import {
TxError,
CompensationFailedError,
RetryExhaustedError,
TransactionTimeoutError,
TransactionStateError,
} from '@firsttx/tx';
async function handleTransaction() {
try {
const tx = startTransaction({ timeout: 5000 });
await tx.run(() => updateUI(), { compensate: () => revertUI() });
await tx.run(() => fetch('/api/update'), { retry: { maxAttempts: 3 } });
await tx.commit();
toast.success('Update successful');
} catch (error) {
if (error instanceof CompensationFailedError) {
// Critical: data may be inconsistent
alert(error.getUserMessage());
window.location.reload();
} else if (error instanceof RetryExhaustedError) {
// Network issue: offer retry
toast.error(error.getUserMessage(), {
action: {
label: 'Retry',
onClick: () => handleTransaction(),
},
});
} else if (error instanceof TransactionTimeoutError) {
// Slow operation: suggest waiting
toast.error(error.getUserMessage());
} else if (error instanceof TransactionStateError) {
// Programming error: log for debugging
console.error(error.getDebugInfo());
Sentry.captureException(error);
} else if (error instanceof TxError) {
// Generic TxError fallback
toast.error(error.getUserMessage());
console.error(error.getDebugInfo());
}
}
}
`
---
All transaction errors provide:
| Method | Returns | Purpose |
| ------------------ | --------- | ---------------------------------------------------- |
| getUserMessage() | string | User-friendly message for UI display |getDebugInfo()
| | string | Detailed technical information for debugging |isRecoverable()
| | boolean | Whether the error can be recovered from (retry-able) |
Recoverability
- ✅ RetryExhaustedError: Recoverable (network/temporary issues)TransactionTimeoutError
- ✅ : Recoverable (retry with longer timeout)CompensationFailedError
- ❌ : Not recoverable (data inconsistency)TransactionStateError
- ❌ : Not recoverable (programming error)
---
`typescript
import {
// Main API
startTransaction,
useTx,
// Error Classes
TxError, // Base class
CompensationFailedError,
RetryExhaustedError,
TransactionTimeoutError,
TransactionStateError,
// Types
type TxOptions,
type TxStatus,
type StepOptions,
type RetryConfig,
type UseTxConfig,
type UseTxResult,
// Constants
DEFAULT_RETRY_CONFIG,
} from '@firsttx/tx';
`
---
`tsx`
// ❌ Not supported
const tx1 = startTransaction();
await tx1.run(async () => {
const tx2 = startTransaction(); // Avoid this
await tx2.run(...);
});
Workaround Use a single transaction with multiple steps.
---
Compensation functions may be called multiple times in edge cases.
`tsx
// ✅ Good: Idempotent compensation
compensate: () => {
setCount(0); // Always safe to call multiple times
};
// ❌ Bad: Non-idempotent compensation
compensate: () => {
setCount(count - 1); // Dangerous if called twice
};
`
---
`tsx
// ❌ Sync compensation not supported
compensate: () => {
updateState();
};
// ✅ Async compensation required
compensate: async () => {
updateState();
};
`
---
Q: What happens if compensation fails?
A: CompensationFailedError is thrown with all failure details. The transaction is marked as 'failed', and you should handle it as a critical error (e.g., page refresh).
---
Q: Can I retry a failed transaction?
A: Yes, just call the same function again. Each transaction is independent.
---
Q: What's the difference between mutate and mutateAsync?
A:
- mutate: Fire-and-forget (no return value, errors caught internally)mutateAsync
- : Returns a Promise (allows await, errors bubble up)
---
Q: Do I need to use ViewTransition?
A: No, it's optional. Set transition: false to disable. ViewTransition provides smooth animations but requires browser support (Chrome 111+).
---
Q: How does retry backoff work?
A:
- Exponential (default): 100ms → 200ms → 400ms → 800ms (delay × 2^attempt)100ms → 200ms → 300ms → 400ms
- Linear: (delay × attempt)
---
- @firsttx/local-first - IndexedDB + React integration
- @firsttx/prepaint` - Instant page restoration
---
MIT © joseph0926