SPA Hooks
Run Durably entirely in the browser using SQLite WASM with OPFS persistence. Jobs execute client-side with data stored in the browser's Origin Private File System.
import {
DurablyProvider,
useDurably,
useJob,
useJobRun,
useJobLogs,
useRuns,
} from '@coji/durably-react/spa'DurablyProvider
Wraps your app and initializes Durably with a browser SQLite database.
import { DurablyProvider } from '@coji/durably-react/spa'
import { createDurably } from '@coji/durably'
import { SQLocalKysely } from 'sqlocal/kysely'
const sqlocal = new SQLocalKysely('app.sqlite3')
const durably = createDurably({
dialect: sqlocal.dialect,
pollingIntervalMs: 100,
maxConcurrentRuns: 1, // default; raise to process multiple runs concurrently (storage still enforces concurrencyKey)
jobs: {
myJob: myJobDef,
},
})
await durably.init()
function App() {
return (
<DurablyProvider durably={durably} fallback={<p>Loading...</p>}>
<MyComponent />
</DurablyProvider>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
durably | Durably | Promise<Durably> | required | Durably instance or Promise |
autoStart | boolean | true | Auto-start worker on mount |
onReady | (durably: Durably) => void | - | Callback when ready |
fallback | ReactNode | - | Loading fallback (wraps in Suspense) |
useDurably
Access the Durably instance directly.
import { useDurably } from '@coji/durably-react/spa'
function Component() {
const { durably, isReady, error } = useDurably()
if (!isReady) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
// Use durably instance directly
const runs = await durably.storage.getRuns()
}Return Type
| Property | Type | Description |
|---|---|---|
durably | Durably | null | The Durably instance |
isReady | boolean | Whether Durably is initialized |
error | Error | null | Initialization error |
useJob
Trigger and monitor a job. Pass a JobDefinition to get type-safe input/output.
import { defineJob } from '@coji/durably'
import { useJob } from '@coji/durably-react/spa'
import { z } from 'zod'
const myJob = defineJob({
name: 'my-job',
input: z.object({ value: z.string() }),
output: z.object({ result: z.number() }),
run: async (step, input) => {
const data = await step.run('process', () => process(input.value))
return { result: data.length }
},
})
function Component() {
const {
trigger,
triggerAndWait,
status,
output,
error,
logs,
progress,
isLeased,
isPending,
isCompleted,
isFailed,
isCancelled,
isTerminal,
isActive,
isResolving,
currentRunId,
reset,
} = useJob(myJob, {
scope: { labels: { entityId: 'entity_123' } },
triggerOptions: {
labels: { entityId: 'entity_123' },
concurrencyKey: 'entity:entity_123',
coalesce: 'active',
},
})
const handleClick = async () => {
const { runId } = await trigger({ value: 'test' })
console.log('Started:', runId)
}
return (
<div>
<button onClick={handleClick} disabled={isLeased}>
Run
</button>
<p>Status: {status}</p>
{progress && (
<p>
Progress: {progress.current}/{progress.total}
</p>
)}
{isCompleted && <p>Result: {output?.result}</p>}
{isFailed && <p>Error: {error}</p>}
<button onClick={reset}>Reset</button>
</div>
)
}Options
| Option | Type | Default | Description |
|---|---|---|---|
initialRunId | string | - | Track and hydrate this run immediately; skips scoped auto-resume |
autoResume | boolean | true | Find a matching leased run, then a matching pending run, then a matching waiting run |
followLatest | boolean | true | Follow matching trigger, coalesced, and leased events |
scope | { labels: Record<string, string> } | - | Require every label for resume and follow |
triggerOptions | TriggerOptions<Record<string,string>> | - | Forward options to both trigger and triggerAndWait |
Matching run:trigger events are followed while the new run is still pending. This is the pending-time follow behavior. Scope labels and trigger labels are independent, so supply both when a newly triggered run should match the hook. Scope changes clear the previous run and resolve the new scope. Inline option objects are compared by value.
Return Type
interface UseJobResult<TInput, TOutput> {
trigger: (input: TInput) => Promise<{ runId: string }>
triggerAndWait: (input: TInput) => Promise<{ runId: string; output: TOutput }>
status: RunStatus | null
output: TOutput | null
error: string | null
logs: LogEntry[]
progress: Progress | null
isLeased: boolean
isPending: boolean
isCompleted: boolean
isFailed: boolean
isCancelled: boolean
isTerminal: boolean
isActive: boolean
isWaiting: boolean
isResolving: boolean
currentRunId: string | null
reset: () => void
}useJobRun
Subscribe to an existing run by ID.
import { useJobRun } from '@coji/durably-react/spa'
function RunMonitor({ runId }: { runId: string | null }) {
const {
status,
output,
error,
progress,
logs,
isLeased,
isCompleted,
isFailed,
isCancelled,
isTerminal,
isActive,
} = useJobRun<{ result: number }>({ runId })
if (!runId) return <div>No run selected</div>
return (
<div>
<p>Status: {status}</p>
{isCompleted && <p>Output: {JSON.stringify(output)}</p>}
</div>
)
}Options
| Option | Type | Description |
|---|---|---|
runId | string | null | The run ID to subscribe to |
Return Type
| Property | Type | Description |
|---|---|---|
status | RunStatus | null | Current run status |
output | TOutput | null | Output from completed run |
error | string | null | Error message from failed run |
logs | LogEntry[] | Logs collected during execution |
progress | Progress | null | Current progress |
isLeased | boolean | Whether the run is executing |
isPending | boolean | Whether the run is queued |
isCompleted | boolean | Whether the run completed |
isFailed | boolean | Whether the run failed |
isCancelled | boolean | Whether the run was cancelled |
isTerminal | boolean | Terminal status (completed, failed, or cancelled) |
isActive | boolean | Pending or leased |
isWaiting | boolean | true when status is waiting |
useJobLogs
Subscribe to logs from a run.
import { useJobLogs } from '@coji/durably-react/spa'
function LogViewer({ runId }: { runId: string | null }) {
const { logs, clearLogs } = useJobLogs({
runId,
maxLogs: 100,
})
return (
<div>
<button onClick={clearLogs}>Clear Logs</button>
<ul>
{logs.map((log) => (
<li key={log.id}>
[{log.level}] {log.message}
{log.data && <pre>{JSON.stringify(log.data)}</pre>}
</li>
))}
</ul>
</div>
)
}Options
| Option | Type | Description |
|---|---|---|
runId | string | null | The run ID to subscribe to |
maxLogs | number | Maximum number of logs to keep |
useRuns
List runs with optional filtering, pagination, and real-time updates.
An explicit empty jobName or status array returns no runs. Omit the option to leave it unrestricted.
The hook automatically subscribes to Durably events and refreshes the list when runs change. It listens to:
run:trigger,run:leased,run:waiting,run:complete,run:fail,run:cancel,run:delete- refresh listrun:progress- update progress in placestep:start,step:complete- refresh for step count updates
Generic type parameter (dashboard with multiple job types)
Use a type parameter to specify the run type for dashboards with multiple job types:
import { useRuns, TypedRun } from '@coji/durably-react/spa'
// Define your run types
type ImportRun = TypedRun<{ file: string }, { count: number }>
type SyncRun = TypedRun<{ userId: string }, { synced: boolean }>
type DashboardRun = ImportRun | SyncRun
function Dashboard() {
const { runs } = useRuns<DashboardRun>({ pageSize: 10 })
return (
<ul>
{runs.map((run) => (
<li key={run.id}>
{run.jobName}: {run.status}
{/* Use jobName to narrow the type */}
{run.jobName === 'import-csv' && run.output?.count}
</li>
))}
</ul>
)
}With JobDefinition (single job, auto-filters by jobName)
Pass a JobDefinition to get typed runs and auto-filter by job name:
import { defineJob } from '@coji/durably'
import { useRuns } from '@coji/durably-react/spa'
const myJob = defineJob({
name: 'my-job',
input: z.object({ value: z.string() }),
output: z.object({ result: z.number() }),
run: async (step, input) => {
/* ... */
},
})
function RunList() {
const { runs } = useRuns(myJob, { status: 'completed', pageSize: 10 })
return (
<ul>
{runs.map((run) => (
<li key={run.id}>
{/* run.output is typed as { result: number } | null */}
Result: {run.output?.result}
</li>
))}
</ul>
)
}Without type parameter (untyped)
import { useRuns } from '@coji/durably-react/spa'
function RunList() {
const { runs } = useRuns({ jobName: 'my-job', pageSize: 10 })
return (
<ul>
{runs.map((run) => (
<li key={run.id}>
{/* run.output is unknown */}
{run.jobName}: {run.status}
</li>
))}
</ul>
)
}Signatures
// With type parameter (dashboard)
useRuns<TRun>(options?)
// With JobDefinition (single job, auto-filters)
useRuns(jobDefinition, options?)
// Without type parameter (untyped)
useRuns(options?)Options
| Option | Type | Description |
|---|---|---|
jobName | string | string[] | Filter by job name(s) (only for untyped usage) |
status | RunStatus | RunStatus[] | Filter by status(es) — array matches any |
labels | Record<string, string> | Filter by labels — all specified labels must match |
pageSize | number | Number of runs per page (default: 10) |
realtime | boolean | Subscribe to real-time updates (default: true) |
Return Type
| Property | Type | Description |
|---|---|---|
runs | TypedRun<TInput, TOutput>[] | List of runs (typed when using JobDefinition) |
page | number | Current page (0-indexed) |
hasMore | boolean | Whether more pages exist |
isLoading | boolean | Loading state |
nextPage | () => void | Go to next page |
prevPage | () => void | Go to previous page |
goToPage | (page: number) => void | Go to specific page |
refresh | () => Promise<void> | Manually refresh the list |
isWaiting is true for suspended runs, including accepted input awaiting a lease. Waiting is nonterminal but is not included in isActive; use !isTerminal for unfinished work.