Skip to content

React Hooks

React bindings for Durably - hooks for triggering and monitoring jobs with real-time updates.

Requirements

  • React 19+ (uses React.use() for Promise resolution)

Which Mode Should I Use?

Durably React provides two modes for different architectures:

QuestionSPA HooksFullstack Hooks
Where do jobs run?In the browserOn the server
Where is data stored?Browser OPFSServer database
Works offline?YesNo
Share state across tabs/users?NoYes
Needs backend?NoYes

Choose Fullstack Hooks when:

  • Building full-stack applications
  • Jobs need server resources (databases, APIs, secrets)
  • Multiple users or tabs need to see the same state
  • Need persistent storage across devices
tsx
import { createDurably } from '@coji/durably-react'

Fullstack Hooks Reference →

Choose SPA Hooks when:

  • Building offline-capable or local-first apps
  • Data should stay on the user's device
  • Prototyping without a backend
  • Single-user, single-tab usage
tsx
import { DurablyProvider, useJob } from '@coji/durably-react/spa'

SPA Hooks Reference →

Installation

bash
# Fullstack mode - connects to Durably server
pnpm add @coji/durably-react

# SPA mode - runs Durably in the browser
pnpm add @coji/durably @coji/durably-react kysely zod sqlocal

Quick Examples

Fullstack Mode

Jobs run on the server, with real-time updates via SSE.

tsx
// 1. Create type-safe hooks (client-side file)
import { createDurably } from '@coji/durably-react'
import type { durably } from './durably.server'

export const durably = createDurably<typeof durably>({
  api: '/api/durably',
})

// 2. Use in components
function ImportButton() {
  const { trigger, progress, isLeased, isCompleted, output } =
    durably.importCsv.useJob()

  return (
    <div>
      <button
        onClick={() => trigger({ filename: 'data.csv' })}
        disabled={isLeased}
      >
        Import
      </button>
      {progress && (
        <p>
          {progress.current}/{progress.total}
        </p>
      )}
      {isCompleted && <p>Done: {output?.count} rows</p>}
    </div>
  )
}

SPA Mode

Jobs run entirely in the browser with OPFS persistence.

tsx
import { DurablyProvider, useJob } from '@coji/durably-react/spa'
import { durably } from './lib/durably'
import { importCsvJob } from './jobs/import-csv'

function App() {
  return (
    <DurablyProvider durably={durably} fallback={<p>Loading...</p>}>
      <ImportButton />
    </DurablyProvider>
  )
}

function ImportButton() {
  const { trigger, progress, isLeased, isCompleted, output } =
    useJob(importCsvJob)

  return (
    <div>
      <button
        onClick={() => trigger({ filename: 'data.csv' })}
        disabled={isLeased}
      >
        Import
      </button>
      {progress && (
        <p>
          {progress.current}/{progress.total}
        </p>
      )}
      {isCompleted && <p>Done: {output?.count} rows</p>}
    </div>
  )
}

Available Hooks

Both Modes

HookDescription
useJobTrigger and monitor a job
useJobRunSubscribe to an existing run by ID
useJobLogsSubscribe to logs from a run
useRunsList runs with filtering and pagination

SPA Mode Only

HookDescription
useDurablyAccess the Durably instance directly

Fullstack Mode Only

HookDescription
useRunActionsRetrigger, cancel, delete runs

Common Patterns

Show Progress Bar

tsx
function ProgressBar({ runId }: { runId: string }) {
  const { progress, isLeased } = useJobRun({ runId })

  if (!isLeased || !progress) return null

  const percent = Math.round((progress.current / progress.total) * 100)

  return (
    <div>
      <progress value={progress.current} max={progress.total} />
      <span>
        {percent}% - {progress.message}
      </span>
    </div>
  )
}

Handle Errors

tsx
function JobRunner() {
  const { trigger, isFailed, error, reset } = useJob(myJob)

  if (isFailed) {
    return (
      <div>
        <p>Error: {error}</p>
        <button onClick={reset}>Try Again</button>
      </div>
    )
  }

  return <button onClick={() => trigger({ ... })}>Run</button>
}

Run Dashboard (Fullstack Mode)

tsx
function Dashboard() {
  const { runs, page, hasMore, nextPage, prevPage } = durably.useRuns({
    pageSize: 10,
  })
  const { retrigger, cancel, deleteRun } = durably.useRunActions()

  return (
    <table>
      <thead>
        <tr>
          <th>Job</th>
          <th>Status</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {runs.map((run) => (
          <tr key={run.id}>
            <td>{run.jobName}</td>
            <td>{run.status}</td>
            <td>
              {run.status === 'failed' && (
                <button onClick={() => retrigger(run.id)}>Retrigger</button>
              )}
              {run.status === 'leased' && (
                <button onClick={() => cancel(run.id)}>Cancel</button>
              )}
              <button onClick={() => deleteRun(run.id)}>Delete</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

Type Definitions

See Type Definitions for all exported types.

Released under the MIT License.