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,
    currentRunId,
    reset,
  } = useJob(myJob)

  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

OptionTypeDescription
initialRunIdstringResume subscription to an existing run

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

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

Released under the MIT License.