Skip to content

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

DurablyProvider

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

tsx
import { DurablyProvider } from '@coji/durably-react'
import { createDurably } from '@coji/durably'
import { SQLocalKysely } from 'sqlocal/kysely'

const sqlocal = new SQLocalKysely('app.sqlite3')

const durably = createDurably({
  dialect: sqlocal.dialect,
  pollingInterval: 100,
}).register({
  myJob: myJobDef,
})

await durably.migrate()

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'

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'
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, payload) => {
    const data = await step.run('process', () => process(payload.value))
    return { result: data.length }
  },
})

function Component() {
  const {
    isReady,
    trigger,
    triggerAndWait,
    status,
    output,
    error,
    logs,
    progress,
    isRunning,
    isPending,
    isCompleted,
    isFailed,
    isCancelled,
    currentRunId,
    reset,
  } = useJob(myJob)

  const handleClick = async () => {
    const { runId } = await trigger({ value: 'test' })
    console.log('Started:', runId)
  }

  return (
    <div>
      <button onClick={handleClick} disabled={!isReady || isRunning}>
        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> {
  isReady: boolean
  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
  isRunning: boolean
  isPending: boolean
  isCompleted: boolean
  isFailed: boolean
  isCancelled: boolean
  currentRunId: string | null
  reset: () => void
}

useJobRun

Subscribe to an existing run by ID.

tsx
import { useJobRun } from '@coji/durably-react'

function RunMonitor({ runId }: { runId: string | null }) {
  const {
    isReady,
    status,
    output,
    error,
    progress,
    logs,
    isRunning,
    isCompleted,
    isFailed,
    isCancelled,
  } = 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

useJobLogs

Subscribe to logs from a run.

tsx
import { useJobLogs } from '@coji/durably-react'

function LogViewer({ runId }: { runId: string | null }) {
  const { isReady, 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:start, run:complete, run:fail, run:cancel, run:retry - 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'

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

const myJob = defineJob({
  name: 'my-job',
  input: z.object({ value: z.string() }),
  output: z.object({ result: z.number() }),
  run: async (step, payload) => { /* ... */ },
})

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'

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
jobNamestringFilter by job name (only for untyped usage)
statusRunStatusFilter by status
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.