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.
import { createDurably } from '@coji/durably-react'Server Setup
All approaches below require a Durably server with HTTP handler:
// 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
| Option | Type | Description |
|---|---|---|
sseThrottleMs | number | Throttle interval for SSE progress events (default: 100ms, 0 to disable) |
onRequest | () => Promise<void> | void | Called before handling each request (after authentication) |
auth | AuthConfig | Auth middleware configuration (see Auth guide) |
// 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.
// 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.
// 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:
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.
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.
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
| Option | Type | Default | Description |
|---|---|---|---|
api | string | - | API base path (e.g., /api/durably) |
jobName | string | - | Name of the job to trigger |
initialRunId | string | - | Track this run immediately and skip scoped auto-resume |
autoResume | boolean | true | Find a matching leased run, then a matching pending run, then a matching waiting run |
followLatest | boolean | true | Follow matching trigger, coalesced, and leased SSE events |
scope | { labels: Record<string, string> } | - | Filter lookups and subscriptions by every supplied label |
triggerOptions | TriggerOptions<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
| Property | Type | Description |
|---|---|---|
isTerminal | boolean | true when status is completed, failed, or cancelled |
isActive | boolean | true when status is pending or leased |
isWaiting | boolean | true when status is waiting |
isResolving | boolean | true while the initial scoped lookup is in progress |
| … | … | See Types — same boolean helpers as isPending, etc. |
useJobRun
Subscribe to an existing run via SSE.
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
| Option | Type | Description |
|---|---|---|
api | string | API base path |
runId | string | The run ID to subscribe to |
onStart | () => void | Called when the run transitions to pending/leased |
onComplete | () => void | Called when the run completes |
onFail | () => void | Called when the run fails |
useJobLogs
Subscribe to logs from a run via SSE.
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
| Option | Type | Description |
|---|---|---|
api | string | API base path |
runId | string | The run ID to subscribe to |
maxLogs | number | Maximum 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 listrun:progress- update progress in placestep: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:
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:
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)
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
// With type parameter (dashboard)
useRuns<TRun>(options)
// With JobDefinition (single job, auto-filters)
useRuns(jobDefinition, options)
// Without type parameter (untyped)
useRuns(options)Options
| Option | Type | Description |
|---|---|---|
api | string | API base path |
jobName | string | string[] | Filter by job name(s) (only for untyped usage) |
status | RunStatus | RunStatus[] | Filter by status(es) — array matches any |
labels | Record<string, string> | Filter by labels — all specified labels must match |
pageSize | number | Number of runs per page |
realtime | boolean | Subscribe to SSE updates on first page (default: true) |
Return Type
| Property | Type | Description |
|---|---|---|
runs | TypedClientRun<TInput, TOutput>[] | List of runs (typed when using JobDefinition) |
isLoading | boolean | Loading state |
error | string | null | Error message |
page | number | Current page (0-indexed) |
hasMore | boolean | Whether more pages exist |
nextPage | () => void | Go to next page |
prevPage | () => void | Go to previous page |
goToPage | (page: number) => void | Go to specific page |
refresh | () => void | Refresh 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).
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
| Option | Type | Description |
|---|---|---|
api | string | API base path |
Return Type
| Property | Type | Description |
|---|---|---|
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.