@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
379 lines (283 loc) • 12.8 kB
Markdown
# @tanstack/offline-transactions
This package gives the leader a durable outbox for pending TanStack DB mutations. It retries stored mutations when the server is available. Read the [Offline Transactions guide](../../docs/guides/offline-transactions.md) for setup and lifecycle behavior.
## Features
- **Outbox**: The leader stores mutations before sending them when `isOfflineEnabled` is true
- **Automatic Retry**: Configurable retry behavior with exponential backoff + jitter by default
- **Multi-tab Coordination**: Leader election chooses one tab to process the outbox
- **FIFO Sequential Processing**: Transactions execute one at a time in creation order
- **Flexible Storage**: IndexedDB with localStorage fallback
- **Type Safe**: Full TypeScript support with TanStack DB integration
## Installation
### Web
```bash
npm install @tanstack/offline-transactions
```
### React Native / Expo
```bash
npm install @tanstack/offline-transactions @react-native-community/netinfo
```
The React Native entry point uses `@react-native-community/netinfo` for connectivity detection. Supply a `StorageAdapter` for the outbox. The package does not include an AsyncStorage adapter.
## Platform Support
This package provides platform-specific implementations for web and React Native environments:
- **Web**: Uses browser APIs (`window.online` and `document.visibilitychange` events). Visible tabs allow sync attempts even when `navigator.onLine` is false; hidden tabs follow that hint. Request failures still use the configured retry policy. Visibility is local to each tab and does not transfer leadership from a hidden tab.
- **React Native**: Uses React Native primitives (`@react-native-community/netinfo` for network status, `AppState` for foreground/background detection)
## Quick Start
Use the entry point for your platform. React Native also needs a storage adapter, as shown in the [guide](../../docs/guides/offline-transactions.md#react-native-and-expo).
**Web:**
```typescript
import { startOfflineExecutor } from '@tanstack/offline-transactions'
```
**React Native / Expo:**
```typescript
import { startOfflineExecutor } from '@tanstack/offline-transactions/react-native'
```
**Web usage:**
```typescript
// Setup offline executor
const offline = startOfflineExecutor({
collections: { todos: todoCollection },
mutationFns: {
syncTodos: async ({ transaction, idempotencyKey }) => {
await api.saveBatch(transaction.mutations, { idempotencyKey })
},
},
onLeadershipChange: (isLeader) => {
if (!isLeader) {
console.warn('Running in online-only mode (another tab is the leader)')
}
},
})
await offline.waitForInit()
// Create offline transactions
const offlineTx = offline.createOfflineTransaction({
mutationFnName: 'syncTodos',
autoCommit: false,
})
const transaction = offlineTx.mutate(() => {
todoCollection.insert({
id: crypto.randomUUID(),
text: 'Buy milk',
completed: false,
})
})
// Commit can remain pending while offline. Observe final failure.
void offlineTx.commit().catch((error) => console.error(error))
void transaction.isPersisted.promise.catch((error) => console.error(error))
```
On React Native, pass a custom `storage` adapter to `startOfflineExecutor`.
## Core Concepts
### Durable Outbox
When `isOfflineEnabled` is true, the executor records a mutation before it sends the mutation to the server. The optimistic change appears before the outbox write settles:
1. The Collection applies an optimistic mutation.
2. The leader writes the transaction to the outbox.
3. When online, the executor calls the named mutation function.
4. After a successful call, the executor attempts to remove the outbox entry.
An optimistic change does not prove that the outbox write succeeded. Handle transaction failures, and use an idempotency key on the server because an attempt can run more than once.
### Multi-tab Coordination
Only one tab acts as the "leader" to safely manage the outbox:
- **Leader tab**: Full offline support with outbox persistence
- **Non-leader tabs**: Online-only mode for safety
- **Leadership transfer**: Automatic failover when leader tab closes
### FIFO Sequential Processing
The executor processes one transaction at a time, in creation order:
- **Sequential execution**: All transactions execute in FIFO order
- **Dependency safety**: Avoids conflicts between transactions that may reference each other
- **Predictable behavior**: Transactions complete in creation order
## API Reference
### startOfflineExecutor(config)
Creates and starts an offline executor instance.
```typescript
interface OfflineConfig {
collections: Record<string, Collection>
mutationFns: Record<string, MutationFn>
storage?: StorageAdapter
maxConcurrency?: number
jitter?: boolean
beforeRetry?: (transactions: OfflineTransaction[]) => OfflineTransaction[]
onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void
onLeadershipChange?: (isLeader: boolean) => void
onlineDetector?: OnlineDetector
}
```
### OfflineExecutor
#### Properties
- `isOfflineEnabled: boolean` - Whether this tab can persist offline transactions
#### Methods
- `createOfflineTransaction(options)` - Create a manual offline transaction
- `waitForTransactionCompletion(id)` - Wait for a specific transaction to complete
- `removeFromOutbox(id)` - Manually remove transaction from outbox
- `peekOutbox()` - View all pending transactions
- `dispose()` - Clean up resources
### Error Handling
Use `NonRetriableError` for permanent failures:
```typescript
import { NonRetriableError } from '@tanstack/offline-transactions'
const mutationFn = async ({ transaction }) => {
try {
await api.save(transaction.mutations)
} catch (error) {
if (error.status === 422) {
throw new NonRetriableError('Invalid data - will not retry')
}
throw error // Will retry with backoff
}
}
```
## Advanced Usage
### Custom Storage Adapter
```typescript
import {
IndexedDBAdapter,
LocalStorageAdapter,
} from '@tanstack/offline-transactions'
const executor = startOfflineExecutor({
// Use custom storage
storage: new IndexedDBAdapter('my-app', 'transactions'),
// ... other config
})
```
### Manual Transaction Control
```typescript
const tx = executor.createOfflineTransaction({
mutationFnName: 'syncData',
autoCommit: false,
})
tx.mutate(() => {
collection.insert({ id: '1', text: 'Item 1' })
collection.insert({ id: '2', text: 'Item 2' })
})
// Commit when ready
await tx.commit()
```
## Tracking Submission Status
The `Transaction` returned by `mutate()` exposes local transaction state. Its
`state` starts as `pending`, becomes `persisting` during commit, and settles as
`completed` or `failed`. A rollback can move it to `failed` before or during
persistence. `isPersisted.promise` settles at the same success or failure
boundary.
On the offline-execution path, successful settlement means the configured
`mutationFn` returned and the storage adapter acknowledged outbox deletion.
It means the server confirmed or exposed the write only when that
`mutationFn` explicitly waits for the provider's acknowledgement, read-back, or
sync observation before returning.
After `mutationFn` returns, the executor records a `deletion-pending` outbox
phase before removing the row. If recording that phase or removing the row
fails, `commit()`, `isPersisted.promise`, and the per-ID completion waiter reject
with the storage error. The executor stops: it does not retry the deletion,
process queued peers, or admit new transactions. A fresh executor can remove a
marked row without calling `mutationFn` again; `beforeRetry` only filters rows
whose provider work is still pending. An unmarked row can replay after a crash
or a failed phase write, so providers must honor the supplied `idempotencyKey`.
Outbox removal means the storage adapter acknowledged deletion; it does not
establish physical power-loss durability or exactly-once provider execution.
If an app removes a row during an active provider call, that caller still waits
for the provider call to return before it settles.
After a permanent provider failure, the caller rejects with that provider
error. The executor records a `rejection-pending` outbox phase before removing
the row. If the phase write or deletion fails, the executor batch throws the
storage error and stops with queued peers untouched. A fresh executor skips
provider work and optimistic restoration for a marked row. If writing the
marker failed, the unmarked row may replay after restart.
```typescript
const offlineTx = offline.createOfflineTransaction({
mutationFnName: 'syncTodos',
autoCommit: false,
})
const tx = offlineTx.mutate(() => {
todoCollection.insert({ id: '1', text: 'Buy milk', completed: false })
})
console.log(tx.state) // 'pending'
try {
await Promise.all([offlineTx.commit(), tx.isPersisted.promise])
console.log(tx.state) // 'completed'
} catch (error) {
showSubmissionError(error)
}
```
### Tracking Every Pending Transaction for an Item
An item can have more than one transaction in flight. Track transaction
identities rather than storing one replaceable boolean or deleting an
item-keyed entry unconditionally:
```typescript
import type { Transaction } from '@tanstack/db'
const pendingByItem = new Map<string, Set<Transaction>>()
function trackPending(itemId: string, tx: Transaction) {
const pending = pendingByItem.get(itemId) ?? new Set<Transaction>()
pending.add(tx)
pendingByItem.set(itemId, pending)
const removeThisTransaction = () => {
pending.delete(tx)
if (pending.size === 0 && pendingByItem.get(itemId) === pending) {
pendingByItem.delete(itemId)
}
}
// Handle fulfillment and rejection so cleanup does not create another
// rejected Promise chain.
void tx.isPersisted.promise.then(removeThisTransaction, removeThisTransaction)
}
function isPending(itemId: string): boolean {
return (pendingByItem.get(itemId)?.size ?? 0) > 0
}
```
Using only `pendingItems.delete(itemId)` in an older transaction's completion
handler is unsafe because it can remove a newer transaction's status. A map that
represents only "the latest submission" must compare the current entry with the finishing transaction before cleanup:
```typescript
if (latestByItem.get(itemId) === tx) {
latestByItem.delete(itemId)
}
```
That latest-only map can still be empty while an older transaction is pending
if the newer transaction settles first. Use a set as above when the UI must
answer whether _any_ submission remains pending.
### Inspecting Offline Work
The executor exposes point-in-time scheduler counts and the durable outbox:
```typescript
// Scheduled entries. The pending count can include the currently running entry.
const pendingCount = offline.getPendingCount()
const runningCount = offline.getRunningCount()
// Durable entries, including retry metadata.
const outbox = await offline.peekOutbox()
for (const entry of outbox) {
console.log(entry.id, entry.retryCount, entry.lastError)
}
```
These values describe local executor work, not backend confirmation. A normal
retriable error leaves the transaction queued. A `NonRetriableError` marks a
permanent failure, rejects the caller, and rolls back its optimistic state.
The outbox entry remains until storage acknowledges its removal.
## Migration from TanStack DB
This package uses explicit offline transactions to provide offline capabilities:
```typescript
// Before: Standard TanStack DB (online only)
todoCollection.insert({ id: '1', text: 'Buy milk' })
// After: Explicit offline transactions
const offline = startOfflineExecutor({
collections: { todos: todoCollection },
mutationFns: {
syncTodos: async ({ transaction }) => {
await api.sync(transaction.mutations)
},
},
})
await offline.waitForInit()
const tx = offline.createOfflineTransaction({
mutationFnName: 'syncTodos',
autoCommit: false,
})
tx.mutate(() => todoCollection.insert({ id: '1', text: 'Buy milk' }))
await tx.commit() // Waits for the mutation function and outbox deletion.
```
## Platform Support
### Web Browsers
- **IndexedDB**: Modern browsers (primary storage)
- **localStorage**: Fallback for limited environments
- **Web Locks API**: Chrome 69+, Firefox 96+ (preferred leader election)
- **BroadcastChannel**: All modern browsers (fallback leader election)
### React Native
- **React Native**: 0.70+ (package peer dependency)
- **Expo**: Use the React Native entry point
- **Required peer dependency**: `@react-native-community/netinfo` for network connectivity detection
- **Storage**: Supply a custom `StorageAdapter`, such as the [example AsyncStorage adapter](../../examples/react-native/offline-transactions/src/db/AsyncStorageAdapter.ts)
## License
MIT