Hooks

useWorker

Run a pure function off the main thread: the function is stringified into a Blob worker, every call is a promise matched back by id so out-of-order completions land on the right caller, transferables move in both directions, task errors reject without replacing the worker, and everything is terminated on unmount.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The work itself. **It is stringified, not captured.**
 *
 * `task.toString()` is dropped into a Blob and that Blob becomes the worker's whole program, so
 * the function arrives in the other thread as text with no scope around it. Everything it can see
 * is what it declares itself plus the worker's own globals (`self`, `fetch`, `setTimeout`,
 * `crypto`, `TextEncoder`, `WebAssembly`, `importScripts`). Anything it closes over — a module
 * import, a constant from the file above it, a piece of component state, `window`, `document` —
 * simply is not there, and the failure is a `ReferenceError` raised inside the worker, reported
 * through `error` and through the rejection of the call that hit it.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-worker.json

Prompt

Build a React + TypeScript "useWorker" hook (React only; wraps the native Worker, Blob and
URL.createObjectURL APIs — no dependencies).

Contract
- `useWorker<TInput, TOutput>(
    task: (input: TInput) => TOutput | Promise<TOutput>,
    options?: {
      timeout?: number             // per call, ms; default 0 = no limit
      terminateOnTimeout?: boolean // default true
      transferResult?: boolean     // default true
      validateInput?: boolean      // default true
    },
  ): {
    run: (input: TInput, runOptions?: {
      transfer?: Transferable[]
      signal?: AbortSignal
      timeout?: number
    }) => Promise<TOutput>
    busy: number          // calls posted and not yet settled
    isBusy: boolean
    isSupported: boolean
    error: Error | null   // the last failure, mirrored for rendering
    spawnCount: number    // how many workers this hook has started
    terminate: () => void
  }`
- `run`, `terminate` have stable identities. `run` changes identity only when the *source text*
  of `task` changes.
- Every rejection is an `Error` with a discriminating `name`: `DataCloneError`, `AbortError`,
  `WorkerTimeoutError`, `WorkerTerminatedError`, `WorkerCrashError`, `WorkerSourceError`,
  `WorkerUnsupportedError` — plus, for a task that threw, whatever name the task's own error
  carried (`RangeError`, `TypeError`, …). Do not wrap a task error in a hook error: the point of
  moving code to another thread is that it still fails like code.

Behavior
- **The task is stringified, not captured.** `task.toString()` goes into a Blob, and that Blob is
  the worker's entire program (`const __task = (<source>); self.addEventListener("message", …)`).
  Say this in the type's own docs, because it is the one thing that surprises everybody: the
  function cannot close over imports, module constants, component state, `window` or `document`.
  Everything in, through `input`; everything out, through the return value. Nested helpers defined
  inside the body are fine — they are part of the same text.
- **The worker's identity is that source text.** Consumers pass inline arrows, so keying the
  worker on the function reference would terminate and respawn a thread on every unrelated render
  and kill the calls in flight each time. Memoize on `task.toString()`; only a real edit to the
  task replaces the worker. This is the same trick as keying an observer on a serialized options
  object.
- **Two function shapes need repair before they are wrapped in parentheses.** A bound or native
  function stringifies to `function () { [native code] }` — it parses and then silently does
  nothing, so refuse it up front with `WorkerSourceError`. A method shorthand (`{ hash(x) {…} }`)
  stringifies to `hash(x) {…}`, which is not an expression; re-attach the `function` keyword
  (peel a leading `async` first, or `async (x) => …` is mistaken for a method named `async`).
- **Spawn lazily, on the first `run`.** A hook that opens a thread nobody uses costs a thread and
  a heap for nothing, and SSR has no `Worker` at all. Detect support with `useSyncExternalStore`
  and a server snapshot pinned to `false`, never with a bare `typeof Worker` read during render.
- **One id per call, one promise per id.** Increment a ref and register the call in a `Map` in the
  same synchronous step as the `postMessage`, so two calls fired from one handler can never share
  an id. The worker echoes the id back; look it up, delete it, settle that promise. A miss means
  the call was already abandoned (aborted, timed out, or belonged to a worker that is gone) — drop
  the answer silently. This is what lets an async task finish three calls in the wrong order and
  still hand each caller its own result.
- **`busy` is the queue depth, not a boolean.** Recompute it from the map size after every change,
  reading the size *outside* the state updater so the updater stays pure under StrictMode.
- **A task error keeps the worker.** Wrap the call inside the worker in try/catch (and attach a
  rejection handler to the returned promise), and post back `{ id, ok: false, name, message,
  stack }`. Rebuild an `Error` on the main side with that name and message, keep the worker's
  stack because that is where it happened, reject the call and mirror it into `error`. Never
  terminate: a task that rejects for one input is not a broken thread, and `spawnCount` staying
  at 1 across a hundred failures is the observable proof.
- **A worker crash replaces the worker.** The `error` event means the program itself failed (a
  body that will not parse, a failed `importScripts`) — reject everything in flight with
  `WorkerCrashError`, terminate, revoke the URL, and let the next `run` spawn a fresh one.
  `messageerror` is different: a reply arrived that could not be deserialised, and the id was
  inside it, so the call cannot be identified — reject every open call, but keep the worker.
- **Refuse a payload that cannot cross, with the path.** Before posting, walk `input` and report
  the first value structured clone would throw on: functions, symbols, promises, weak
  collections, DOM nodes, windows. Report the path (`input.rows[2].onSelect is a function`),
  because the native `DataCloneError` names nothing at all and is close to undebuggable on a
  nested payload. Treat `Date`, `RegExp`, `ArrayBuffer`, typed arrays, `Blob`, `ImageData` and
  `ImageBitmap` as leaves; follow cycles once and then stop, since structured clone handles cycles
  fine and the check must not be stricter than the thing it predicts. Do **not** refuse class
  instances: they clone as plain data, losing their prototype and methods — that belongs in the
  docs, not in a thrown error. Keep `postMessage` in a try/catch anyway for whatever the walk was
  told not to look at, and unregister the call before rejecting so a failed post leaves no phantom
  in `busy`.
- **The result must always answer.** Inside the worker, wrap the reply `postMessage` in try/catch
  too: if the *returned* value will not clone (a function, a DOM node), post a `DataCloneError`
  reply instead. A call that never settles is far worse than a call that fails.
- **Transferables, both ways.** `run(input, { transfer })` passes the list straight to
  `postMessage`; document loudly that a transferred buffer is detached on the sending side the
  moment the call returns (`byteLength === 0`). For the way back, scan the returned value one
  level deep — the value itself, array items, own property values — for `ArrayBuffer`s (or views,
  whose `.buffer` travels), dedupe them (a repeated entry in a transfer list is itself a
  `DataCloneError`) and transfer them. Keep the scan shallow and say so: predictable beats clever,
  and a buffer nested three levels down is simply copied.
- **`signal` and `timeout` stop the waiting, not the work.** A worker cannot be interrupted, only
  killed. Both unregister the call and reject it immediately (`AbortError`, respecting
  `signal.reason` when it is an Error, the way `fetch` does; `WorkerTimeoutError` otherwise), and
  the late answer is dropped on the id lookup. A timeout additionally terminates by default:
  a task that blew its budget is usually stuck, and a stuck worker blocks every later call
  forever, so killing it is the only interrupt on offer. `terminateOnTimeout: false` is the escape
  for tasks that are merely slow.
- **Clean up everything, on unmount and on a task change.** Terminate the worker, revoke its Blob
  URL, clear the timeout, remove the abort listener, reject every in-flight promise with
  `WorkerTerminatedError` and a message that names the possible causes. Revoke the URL when the
  worker dies rather than straight after construction, so one URL lives exactly as long as one
  worker. Guard the cleanup against killing a worker it did not start (a later `run` may already
  have replaced it), and empty the pending map *before* rejecting, since a consumer's `catch` may
  call `run` again.

Rendering & styling
- The hook renders nothing; the consumer owns the UI. Semantic tokens only (`bg-card`,
  `text-foreground`, `text-muted-foreground`, `bg-primary`, `bg-muted`, `text-destructive`,
  `border`, `ring`), merged with `cn()`.
- Show `busy` as a number, not a spinner: the difference between one call and eleven queued
  behind it is the whole story. Put the status text in a container that is `role="status"` and
  `aria-live="polite"` and mount it from the first paint, so a state change is announced rather
  than only repainted — never `assertive`, which would interrupt a screen reader on every call.
- Do not put the native `disabled` attribute on a control the user may be focused on: use
  `aria-disabled` plus an early return in the handler, so focus is never thrown back to `<body>`
  when a run starts. Any cancel affordance must be reachable by keyboard and must not be the only
  way out — `terminate()` is also the recovery path from a stuck worker.
- Any "working" animation needs `motion-reduce:animate-none`, and the state must be readable from
  text alone with animation off. Be aware that a CSS transform animation keeps running on the
  compositor while the main thread is blocked, so it is exactly the wrong instrument for proving
  the work moved off-thread — a JavaScript heartbeat is the honest one.
- Render the failure `name` next to the message; the whole reason for discriminating names is that
  `AbortError` and `WorkerCrashError` deserve different UI (one is silence, one is a retry).

Customization levers
- Concurrency — one hook is one worker, and a synchronous task therefore serialises. For real
  parallelism, instantiate the hook N times (N ≈ `navigator.hardwareConcurrency - 1`) and dispatch
  to the least busy instance, or lift the same map-of-pending-calls design into a small pool.
- Fallback — when `isSupported` is false the task is still a plain function you are holding:
  call it directly and accept the main-thread cost. That is a one-line fallback at the call site
  and is deliberately not built in, because silently blocking the main thread is not a default.
- Progress — a task that reports progress needs a second message shape. Add an
  `onProgress(id, value)` option, have the worker post `{ id, progress }` between chunks, and
  route those to the callback instead of settling the promise.
- Module workers — swap the Blob for `new Worker(new URL("./task.ts", import.meta.url), { type:
  "module" })` when the task needs imports; keep the same id-correlation layer above it. That is
  the upgrade path out of the no-capture rule.
- Validation cost — `validateInput: false` skips the walk in a hot loop once the shape is known
  good; the browser still throws, only without the path.
- Transfer policy — `transferResult: false` when the task caches the buffer in worker-global state
  and means to reuse it; or transfer explicitly by returning the buffers in a fixed top-level
  shape the shallow scan is guaranteed to find.
- Cancellation that really stops the work — the only way is a `SharedArrayBuffer` flag the task
  polls with `Atomics.load`, which needs cross-origin isolation headers. Worth it for a long
  reducible task; overkill for anything under a second, where terminate-and-respawn is cheaper.
- Warm start — call `run` once with a trivial input on mount to pay the ~5-15ms spawn before the
  user asks for anything.

Concepts

  • Source text is the identity — the worker is keyed on task.toString(), not on the function reference, because consumers pass inline arrows that are a new object every render. Key it on the reference and an unrelated keystroke tears down a thread and kills the calls riding on it; key it on the text and only a real edit to the task replaces anything.
  • A promise per postMessage, correlated by id — the native API is one fire-and-forget send and one undifferentiated stream of replies, so the boilerplate everyone eventually writes is a correlation table. Making it part of the hook is what allows several calls to be in flight at once and finish in the wrong order while each caller still gets its own answer; an id that misses the table is an answer nobody is waiting for, and is dropped rather than mis-delivered.
  • No closure capture — the function crosses the boundary as text, so nothing lexically around it comes along: not an import, not a module constant, not a piece of state. The failure is a ReferenceError raised inside the worker, which is why the honest design takes everything through input and says so in the type.
  • The clone boundary is where the type system stops — TypeScript will happily let a callback ride along inside a payload; structured clone will not. Walking the input first turns the platform's pathless refusal into a named path, and it happens before anything is posted, so a refused call never even starts a worker.
  • Transfer moves, clone copies — a transferred ArrayBuffer costs O(1) instead of O(bytes), and the price is that the buffer on the sending side is detached the instant the call returns. Handing the buffer back out of the task is what returns the memory; treating detachment as a bug instead of the mechanism is the usual first surprise.
  • Errors keep the worker, crashes replace it — a task that throws is caught in the worker and re-raised as a rejection, and the thread lives on; only a program that will not run takes the worker down with its in-flight calls, after which the next call quietly starts a fresh one. Nothing here can be interrupted, though: signal and timeout stop you waiting, and only termination stops the work.

On This Page