Typescript web workers in React
JavaScript is usually a single-threaded language, but when you need to perform computational tasks without interfering with the user interface, web workers can help. Recently, it has become much easier to incorporate web workers in React projects, particularly when using CRA and TypeScript. In this article, you will learn how to incorporate web workers in your CRA/Typescript site. I even have a working example for you!
The traditional way of using a web worker is to instantiate one like this
const worker = new Worker('path/to/worker.js')
This loads and initiates the worker script in a separate thread, and the main thread can communicate with it by passing messages
worker.onmessage = function(event) {
// ...
}
worker.postMessage({custom: 'message'})
In this manner, the worker can perform its computation and then send messages back to the main thread.
Note, however, that the worker script is not imported in the usual way. That's because it needs to be a separate, self-contained script, without access to anything in the main JavaScript code. This is okay for traditional non-transpiled websites that load .js files in the old-fashioned way. But when using webpack, React, and/or Typescript, this poses a challenge.
Fortunately, with recent updates to webpack and React, it is now easy to overcome the previous limitations. Simply include the worker.ts file among your source code as you would any other script and then create the new worker using the following syntax.
const worker = new Worker(new URL('./worker.ts', import.meta.url))
This is where the magic happens, and kudos to the folks who have made this work seamlessly! To be honest I don't understand exactly what that URL looks like at run time, and how and where the worker.ts script gets transpiled and stored in the build output. But the end effect is that it just works! :)
That's a big step toward achieving our goal, but there are some other React-specific considerations, such as where to instantiate the worker within our component, how to communicate with it, and when to terminate the worker thread. Here's the approach I have taken for the example application (mandelbrot-web-worker). In this case, I am using the worker to render to an HTML5 canvas element.
// Adapted from https://github.com/wmhilton/mandelbrot-playground
import { FunctionComponent, useEffect, useState } from "react";
import Opts, { Bounds } from "./Opts";
import { debounce } from "./utils/debounce";
import { pan } from "./utils/pan";
import { zoom } from "./utils/zoom";
type Props = {
width: number
height: number
}
// the initial bounds of the current viewing rect
const initialBounds: Bounds = [{"r":0.06293479950912537,"i":-0.7231095788998697},{"r":0.568352273969803,"i":-0.38616459565220895}]
const Mandelbrot: FunctionComponent<Props> = ({width, height}) => {
// the canvas HTML element
const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(null)
// bounds and worker state variables
const [bounds, setBounds] = useState<Bounds>(initialBounds)
const [worker, setWorker] = useState<Worker | null>(null)
useEffect(() => {
// called once when the component mounts
if (!canvasElement) return
// instantiate the worker - this is where the magic happens
const worker = new Worker(new URL('./worker.ts', import.meta.url))
// transfer control of the canvas to the worker
const offscreenCanvas = canvasElement.transferControlToOffscreen();
worker.postMessage({
canvas: offscreenCanvas,
}, [offscreenCanvas])
// save the worker so we can post other messages elsewhere
setWorker(worker)
return () => {
// terminate the worker when the component unmounts
worker.terminate()
}
}, [canvasElement])
useEffect(() => {
// called when bounds changes
if (!worker) return
const opts: Opts = {
width,
height,
bounds,
N: 1024
}
// communicate the new bounds to the worker
worker.postMessage({opts})
}, [bounds, worker, width, height])
useEffect(() => {
// here we handle all the user interactions (pan/zoom)
if (!canvasElement) return
let prevMouseXY: [number, number] | null = null
let isMouseDown: boolean = false
let internalBounds: Bounds = initialBounds
const onwheel = (event: WheelEvent) => {
event.preventDefault();
internalBounds = zoom(event.offsetX, event.offsetY, event.deltaY, {...internalBounds}, {WIDTH: width, HEIGHT: height})
setBounds(internalBounds)
}
const onmousedown = (event: MouseEvent) => {
isMouseDown = true;
}
const onmouseup = (event: MouseEvent) => {
isMouseDown = false;
prevMouseXY = null;
}
const onmousemove = (event: MouseEvent) => {
event.preventDefault();
if (isMouseDown) {
if (prevMouseXY) {
internalBounds = pan(event.offsetX, event.offsetY, prevMouseXY[0], prevMouseXY[1], {...internalBounds}, {WIDTH: width, HEIGHT: height})
setBounds(internalBounds)
}
prevMouseXY = [event.offsetX, event.offsetY];
}
}
canvasElement.addEventListener("wheel", debounce(onwheel, 100, false, true));
canvasElement.addEventListener("mousedown", onmousedown)
canvasElement.addEventListener("mouseup", onmouseup)
canvasElement.addEventListener("mousemove", debounce(onmousemove, 100, false, true))
setBounds(internalBounds)
return () => {
canvasElement.removeEventListener("wheel", onwheel)
canvasElement.removeEventListener("mousedown", onmousedown)
canvasElement.removeEventListener("mouseup", onmouseup)
canvasElement.removeEventListener("mousemove", onmousemove)
}
}, [canvasElement, width, height])
return (
<div>
<canvas
ref={elmt => {setCanvasElement(elmt)}}
width={width}
height={height}
/>
</div>
)
}
export default Mandelbrot
The first useEffect hook is called once when the component is mounted, and the web worker is terminated when the component unmounts. Control of rendering to the canvas element is passed to the web worker via the offscreen canvas in an initial postMessage to the worker. The second useEffect hook is called whenever the bounds state variable changes, and further postMessage calls are made to communicate the new bounds to the worker. The third useEffect hook sets up the user interactions for panning and zooming, and is in charge of updating the bounds state variable.
Here's the app in action, and the source code!
Thank you William Hilton -- the Mandelbrot calculation and rendering code was adapted from this repo.
(This was my first blog post, so give me some positive feedback and encouragement to write more.)