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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-worker.jsonPrompt
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
ReferenceErrorraised inside the worker, which is why the honest design takes everything throughinputand 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
ArrayBuffercosts 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:
signalandtimeoutstop you waiting, and only termination stops the work.
useSound
A pooled Audio hook for short UI sounds — preloaded voices so fast triggers overlap instead of cutting each other off, a page-wide mute and master volume, every refusal named, and every element released on unmount.
useFps
A frame-rate probe that reports rolling fps over a sliding window of requestAnimationFrame timestamps, counts jank frames, and pauses instead of reporting zero while the tab is hidden.