Skip to content

Fullstack Hooks ​

Connect to a Durably server via HTTP/SSE for real-time job monitoring. Jobs run on the server with updates streamed to the client.

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

Server Setup ​

All approaches below require a Durably server with HTTP handler:

ts
// app/lib/durably.server.ts
import { createDurably, createDurablyHandler } from '@coji/durably'
import { LibsqlDialect } from '@libsql/kysely-libsql'
import { createClient } from '@libsql/client'

const client = createClient({ url: 'file:local.db' })
const dialect = new LibsqlDialect({ client })

export const durably = createDurably({
  dialect,
  maxConcurrentRuns: 1,
  jobs: {
    importCsv: importCsvJob,
    syncUsers: syncUsersJob,
  },
})

export const durablyHandler = createDurablyHandler(durably, {
  sseThrottleMs: 100, // throttle SSE progress events (default: 100ms, 0 to disable)
  onRequest: async () => {
    // called before handling each request (after authentication)
  },
})

await durably.init()

createDurablyHandler Options ​

OptionTypeDescription
sseThrottleMsnumberThrottle interval for SSE progress events (default: 100ms, 0 to disable)
onRequest() => Promise<void> | voidCalled before handling each request (after authentication)
authAuthConfigAuth middleware configuration (see Auth guide)
ts
// app/routes/api.durably.$.ts
import { durablyHandler } from '~/lib/durably.server'
import type { Route } from './+types/api.durably.$'

export async function loader({ request }: Route.LoaderArgs) {
  return durablyHandler.handle(request, '/api/durably')
}

export async function action({ request }: Route.ActionArgs) {
  return durablyHandler.handle(request, '/api/durably')
}

createDurably ​

Create a type-safe client with a Proxy — autocomplete for job names, inferred input/output types, and built-in cross-job hooks.

ts
// app/lib/durably.ts
import { createDurably } from '@coji/durably-react'
import type { durably } from './durably.server'

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

Per-job hooks ​

Each registered job gets useJob, useRun, and useLogs hooks with full type inference. Options match the standalone hooks (UseJobClientOptions, UseJobRunClientOptions, UseJobLogsClientOptions) except api, jobName, and (for run/logs) runId are injected by the factory.

tsx
// Trigger and monitor a job
function CsvImporter() {
  const { trigger, status, output, isLeased } = durably.importCsv.useJob()

  return (
    <button onClick={() => trigger({ rows: [] })} disabled={isLeased}>
      Import
    </button>
  )
}

// Subscribe to an existing run
function RunViewer({ runId }: { runId: string }) {
  const { status, output, progress } = durably.importCsv.useRun(runId)
  return <div>Status: {status}</div>
}

// Subscribe to logs
function LogViewer({ runId }: { runId: string }) {
  const { logs } = durably.importCsv.useLogs(runId)
  return <pre>{logs.map((l) => l.message).join('\n')}</pre>
}

Cross-job hooks ​

useRuns and useRunActions are built into the client — no need to import separately:

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

  return (
    <table>
      <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.isTerminal && (
                <button onClick={() => cancel(run.id)}>Cancel</button>
              )}
              <button onClick={() => deleteRun(run.id)}>Delete</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

Reserved keys

useRuns and useRunActions are reserved names on the proxy. Do not register jobs with these names — the built-in hooks take precedence.


Hooks directly ​

Alternatively, pass api and jobName to each hook. Works with any setup, including SSR.

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

function CsvImporter() {
  const { trigger, status, output, isLeased } = useJob<
    { filename: string },
    { count: number }
  >({
    api: '/api/durably',
    jobName: 'import-csv',
  })

  return (
    <button
      onClick={() => trigger({ filename: 'data.csv' })}
      disabled={isLeased}
    >
      Import
    </button>
  )
}

useJob ​

Trigger and monitor a job.

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

function Component() {
  const {
    trigger,
    triggerAndWait,
    status,
    output,
    error,
    logs,
    progress,
    isLeased,
    isPending,
    isCompleted,
    isFailed,
    isCancelled,
    isTerminal,
    isActive,
    isResolving,
    currentRunId,
    reset,
  } = useJob<
    { userId: string }, // Input type
    { count: number } // Output type
  >({
    api: '/api/durably',
    jobName: 'sync-data',
    initialRunId: undefined, // Optional: resume existing run
    autoResume: true, // Auto-resume leased/pending/waiting jobs on mount
    followLatest: true, // Switch to tracking new runs via SSE
    scope: { labels: { userId: 'user_123' } },
    triggerOptions: {
      labels: { userId: 'user_123' },
      concurrencyKey: 'sync:user_123',
      coalesce: 'active',
    },
  })

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

  return <button onClick={handleClick}>Sync</button>
}

Options ​

OptionTypeDefaultDescription
apistring-API base path (e.g., /api/durably)
jobNamestring-Name of the job to trigger
initialRunIdstring-Track this run immediately and skip scoped auto-resume
autoResumebooleantrueFind a matching leased run, then a matching pending run, then a matching waiting run
followLatestbooleantrueFollow matching trigger, coalesced, and leased SSE events
scope{ labels: Record<string, string> }-Filter lookups and subscriptions by every supplied label
triggerOptionsTriggerOptions<Record<string,string>>-Forward options to both trigger and triggerAndWait

Client mode encodes scope as label.<key> query parameters on both /runs lookups and /runs/subscribe. isResolving stays true until the initial lookup settles or is superseded. Scope changes discard the prior run, reset local trigger precedence, rebuild the subscription, and resolve the new scope. Scope labels are not copied into trigger labels.

Return value ​

PropertyTypeDescription
isTerminalbooleantrue when status is completed, failed, or cancelled
isActivebooleantrue when status is pending or leased
isWaitingbooleantrue when status is waiting
isResolvingbooleantrue while the initial scoped lookup is in progress
……See Types — same boolean helpers as isPending, etc.

useJobRun ​

Subscribe to an existing run via SSE.

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

function Component({ runId }: { runId: string }) {
  const { status, output, error, progress, logs } = useJobRun<{
    count: number
  }>({
    api: '/api/durably',
    runId,
    onStart: () => console.log('Run started'),
    onComplete: () => console.log('Run completed'),
    onFail: () => console.log('Run failed'),
  })

  return <div>Status: {status}</div>
}

Options ​

OptionTypeDescription
apistringAPI base path
runIdstringThe run ID to subscribe to
onStart() => voidCalled when the run transitions to pending/leased
onComplete() => voidCalled when the run completes
onFail() => voidCalled when the run fails

useJobLogs ​

Subscribe to logs from a run via SSE.

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

function Component({ runId }: { runId: string }) {
  const { logs, clearLogs } = useJobLogs({
    api: '/api/durably',
    runId,
    maxLogs: 50,
  })

  return (
    <ul>
      {logs.map((log) => (
        <li key={log.id}>{log.message}</li>
      ))}
    </ul>
  )
}

Options ​

OptionTypeDescription
apistringAPI base path
runIdstringThe run ID to subscribe to
maxLogsnumberMaximum number of logs to keep

useRuns ​

List and paginate job runs with real-time updates on the first page.

The first page (page 0) automatically subscribes to SSE for real-time updates. 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, step:fail - refresh for step updates

Other pages are static and require manual refresh.

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, TypedClientRun } from '@coji/durably-react'

// Define your run types
type ImportRun = TypedClientRun<{ file: string }, { count: number }>
type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }>
type DashboardRun = ImportRun | SyncRun

function Dashboard() {
  const { runs } = useRuns<DashboardRun>({ api: '/api/durably', 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, input) => {
    /* ... */
  },
})

function RunList() {
  const { runs } = useRuns(myJob, { api: '/api/durably', status: 'completed' })

  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({
    api: '/api/durably',
    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
apistringAPI base path
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
realtimebooleanSubscribe to SSE updates on first page (default: true)

Return Type ​

PropertyTypeDescription
runsTypedClientRun<TInput, TOutput>[]List of runs (typed when using JobDefinition)
isLoadingbooleanLoading state
errorstring | nullError message
pagenumberCurrent page (0-indexed)
hasMorebooleanWhether more pages exist
nextPage() => voidGo to next page
prevPage() => voidGo to previous page
goToPage(page: number) => voidGo to specific page
refresh() => voidRefresh current page

useRunActions ​

Perform actions on runs (retrigger, cancel, delete, fetch run, fetch steps). The hook returns only the action functions — no loading or error state. Use useTransition, component state, or per-row busy flags for pending UI; handle errors from rejected promises at the call site (e.g. .catch in event handlers).

tsx
import { useRunActions } from '@coji/durably-react'
import { useState, useTransition } from 'react'

function RunActions({ runId, status }: { runId: string; status: string }) {
  const { retrigger, cancel, deleteRun, getRun, getSteps } = useRunActions({
    api: '/api/durably',
  })
  const [isPending, startTransition] = useTransition()
  const [error, setError] = useState<string | null>(null)

  return (
    <div>
      {(status === 'failed' || status === 'cancelled') && (
        <button
          type="button"
          onClick={() => {
            setError(null)
            startTransition(() =>
              retrigger(runId).catch((e: unknown) =>
                setError(e instanceof Error ? e.message : String(e)),
              ),
            )
          }}
          disabled={isPending}
        >
          Retrigger
        </button>
      )}
      {(status === 'pending' ||
        status === 'leased' ||
        status === 'waiting') && (
        <button
          type="button"
          onClick={() => {
            setError(null)
            startTransition(() =>
              cancel(runId).catch((e: unknown) =>
                setError(e instanceof Error ? e.message : String(e)),
              ),
            )
          }}
          disabled={isPending}
        >
          Cancel
        </button>
      )}
      {(status === 'completed' ||
        status === 'failed' ||
        status === 'cancelled') && (
        <button
          type="button"
          onClick={() => {
            setError(null)
            startTransition(() =>
              deleteRun(runId).catch((e: unknown) =>
                setError(e instanceof Error ? e.message : String(e)),
              ),
            )
          }}
          disabled={isPending}
        >
          Delete
        </button>
      )}
      {error && <span className="error">{error}</span>}
    </div>
  )
}

Options ​

OptionTypeDescription
apistringAPI base path

Return Type ​

PropertyTypeDescription
retrigger(runId: string) => Promise<string>Retrigger a failed run (returns new run ID)
cancel(runId: string) => Promise<void>Cancel a pending, leased, or waiting run
deleteRun(runId: string) => Promise<void>Delete a run
getRun(runId: string) => Promise<ClientRun | null>Get run details
getSteps(runId: string) => Promise<StepRecord[]>Get step details

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.