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.)

image