Skip to content

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.

tsx
import {
  DurablyProvider,
  useDurably,
  useJob,
  useJobRun,
  useJobLogs,
  useRuns,
} from '@coji/durably-react/spa'

DurablyProvider ​

Wraps your app and initializes Durably with a browser SQLite database.

tsx
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 ​

PropTypeDefaultDescription
durablyDurably | Promise<Durably>requiredDurably instance or Promise
autoStartbooleantrueAuto-start worker on mount
onReady(durably: Durably) => void-Callback when ready
fallbackReactNode-Loading fallback (wraps in Suspense)

useDurably ​

Access the Durably instance directly.

tsx
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 ​

PropertyTypeDescription
durablyDurably | nullThe Durably instance
isReadybooleanWhether Durably is initialized
errorError | nullInitialization error

useJob ​

Trigger and monitor a job. Pass a JobDefinition to get type-safe input/output.

tsx
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 ​

OptionTypeDefaultDescription
initialRunIdstring-Track and hydrate this run immediately; skips scoped auto-resume
autoResumebooleantrueFind a matching leased run, then a matching pending run, then a matching waiting run
followLatestbooleantrueFollow matching trigger, coalesced, and leased events
scope{ labels: Record<string, string> }-Require every label for resume and follow
triggerOptionsTriggerOptions<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 ​

ts
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.

tsx
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 ​

OptionTypeDescription
runIdstring | nullThe run ID to subscribe to

Return Type ​

PropertyTypeDescription
statusRunStatus | nullCurrent run status
outputTOutput | nullOutput from completed run
errorstring | nullError message from failed run
logsLogEntry[]Logs collected during execution
progressProgress | nullCurrent progress
isLeasedbooleanWhether the run is executing
isPendingbooleanWhether the run is queued
isCompletedbooleanWhether the run completed
isFailedbooleanWhether the run failed
isCancelledbooleanWhether the run was cancelled
isTerminalbooleanTerminal status (completed, failed, or cancelled)
isActivebooleanPending or leased
isWaitingbooleantrue when status is waiting

useJobLogs ​

Subscribe to logs from a run.

tsx
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 ​

OptionTypeDescription
runIdstring | nullThe run ID to subscribe to
maxLogsnumberMaximum number of logs to keep

useRuns ​

List runs with optional filtering, pagination, and real-time updates.

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 list
  • run:progress - update progress in place
  • step: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:

tsx
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:

tsx
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) ​

tsx
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 ​

ts
// With type parameter (dashboard)
useRuns<TRun>(options?)

// With JobDefinition (single job, auto-filters)
useRuns(jobDefinition, options?)

// Without type parameter (untyped)
useRuns(options?)

Options ​

OptionTypeDescription
jobNamestring | string[]Filter by job name(s) (only for untyped usage)
statusRunStatus | RunStatus[]Filter by status(es) — array matches any
labelsRecord<string, string>Filter by labels — all specified labels must match
pageSizenumberNumber of runs per page (default: 10)
realtimebooleanSubscribe to real-time updates (default: true)

Return Type ​

PropertyTypeDescription
runsTypedRun<TInput, TOutput>[]List of runs (typed when using JobDefinition)
pagenumberCurrent page (0-indexed)
hasMorebooleanWhether more pages exist
isLoadingbooleanLoading state
nextPage() => voidGo to next page
prevPage() => voidGo to previous page
goToPage(page: number) => voidGo 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.

Released under the MIT License.