How to Use Web Workers in React

How to Use Web Workers in React

Featured on Hashnode

React applications utilize the main thread to handle User Interface(UI) rendering and JavaScript execution. This setup can cause performance issues when components run long tasks. We can offload heavy operations to a background thread using Web Workers to prevent the UI from becoming unresponsive.

In this guide, we will learn what Web Workers are and how to use them in React.

Prerequisites

To get the most out of this article, you'll need to be familiar with the following:

  • Javascript fundamentals

  • Basic understanding of React

What is a Web Worker?

A Web Worker is a browser feature that allows you to run scripts in background threads. The worker thread can perform heavy tasks without interfering with the UI, ensuring a responsive user experience.

There are three kinds of Web Workers:

  • Dedicated Workers: These workers are only accessible by the script that created them. This means they can only be used by one script at a time—the one that instantiated them.

  • Shared Workers: They can be accessed by multiple scripts. This means they can be used in multiple browser contexts at the same time.

  • Service Workers: They act as proxy servers that sit between web applications, the browser, and the network. They are used in Progressive Web Apps(PWAs) to enable offline functionality.

In this guide, we will be focusing on Dedicated Workers and how they can be used in a React app.

Dedicated Workers

To create a Dedicated Worker, you need to instantiate the Worker class and pass in the path to the script file you want to run.

const myWorker = new Worker("<worker-file.js>");

This creates a worker thread to run the code in <worker-file.js>.

As convenient as Web Workers are, they have certain limitations:

  • They cannot directly access the DOM

  • The scope of a Web Worker is self instead of window

Due to these limitations, the Worker thread and main thread need special methods/events to communicate with each other.

Messages are shared between the worker and its calling script using the workers' postMessage method. This method accepts any data that needs to be shared between the worker and the main thread. For example:

// UI script
const handleCalculate = (number) => {
    myWorker.postMessage(number);
    console.log("Message posted to worker");
}

The code above is a function which does some calculations. This function sends the number it accepts as an argument to the worker. The worker then performs some computations using this number.

The onmessage event handler allows both the worker and the main thread to listen for messages. This event triggers whenever a message is posted between them.

// worker-file.js
self.onmessage = function (event) {
  console.log("Message received from main script");
  const number = event.data;
  const result = factorial(number);
  console.log("Posting message back to main script");
  self.postMessage(result);
};

The code above demonstrates how the worker processes incoming messages. It accesses the data from the event.data attribute of the message event. The worker calculates the factorial of the received number and sends the result back to the calling script.

We can now respond to the message sent from the worker thread, in the main thread:

// UI script
myWorker.onmessage = (event) => {
  result.textContent = event.data;
  console.log("Message received from worker");
};

To stop a running worker, we can call the workers' terminate method. This kills the worker thread immediately:

myWorker.terminate();

This covers the basics of Web Workers. Now, let's explore how to use them in React!

Using Web Workers in React

First, let's create a React App using Vite. Run the command below:

npm create vite@latest react-worker -- --template react

Follow the instructions shown after installation and open the project in your IDE.

Navigate to the src folder and create a folder named components in it. Create a file named Products.jsx.

In this file, we are going to create a component that displays and filters a large number of product cards. First, let's write out the UI code:

import { useState, useRef, useEffect } from "react";

const CATEGORIES = ["Electronics", "Clothing", "Toys"];

export default function Products() {
  const [products, setProducts] = useState([]);
  const [filteredProducts, setFilteredProducts] = useState([]);
  const [status, setStatus] = useState("loading");
  const [filter, setFilter] = useState("");

  // worker code goes here...

  return (
    <div>
      <div className="filter-container">
        <select
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
          className="product-dropdown"
          disabled={status !== "idle"}
        >
          <option value="">All Products</option>
          {CATEGORIES.map((category) => (
            <option key={category} value={category}>
              {category}
            </option>
          ))}
        </select>

        <div className="product-btns">
          <button
            onClick={handleFilterProduct}
            disabled={status !== "idle" || !filter}
            className="filter-btn"
          >
            Filter Products
          </button>
          <button onClick={handleReset} disabled={status !== "idle"}>
            Reset
          </button>
        </div>
      </div>

      {status === "loading" && <p>Loading...</p>}
      {status === "filtering" && <p>Filtering...</p>}
      {status === "resetting" && <p>Resetting...</p>}

      {filteredProducts.length > 0 ? (
        <ul className="product-list">
          {filteredProducts.map((product) => (
            <li key={product.id} className="product-card">
              <div className="product-name">{product.name}</div>
              <div className="product-price">${product.price}</div>
              <div className="product-category">({product.category})</div>
            </li>
          ))}
        </ul>
      ) : (
        status === "idle" && <p>No products found matching the filters.</p>
      )}
    </div>
  );
}

The code above includes a dropdown for selecting categories to filter by, a button to trigger the filtration, and a list of product cards displayed based on the current filter. It also features a reset button.

For styling, navigate to the App.css file and add the following styles:

#root {
  margin: 0 auto;
  padding: 2rem 0;
  min-width: 400px;
}

body {
  display: flex;
  min-height: 100vh;
  font-family: "Courier New", Courier, monospace;
}

.filter-container {
  display: flex;
  justify-content: space-between;
}
.product-btns {
  display: flex;
  gap: 8px;

  > button {
    border-radius: 0.5rem;
    color: white;
    background-color: #7e3af2;
    padding: 10px 20px;
    font-size: 14px;
    border: none;
    cursor: pointer;

    &:hover {
      background: #6c2bd9;
    }

    &:disabled {
      background: #c1b0de;
      cursor: not-allowed;
    }
  }
}

.product-list {
  list-style: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.product-card {
  border: 1px solid #e1e1e1;
  border-radius: 8px;
  padding: 15px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  background-color: #fff;
  transition: transform 0.2s ease-in-out;

  &:hover {
    transform: translateY(-5px);
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
  }
}

.product-name {
  font-size: 18px;
  font-weight: bold;
  margin-bottom: 10px;
}

.product-price {
  font-size: 16px;
  color: #333333;
}

.product-category {
  font-size: 14px;
  color: #666;
  margin-top: 5px;
}

p {
  text-align: center;
}

.product-dropdown {
  padding: 10px 25px 10px 10px;
  background-color: #fff;
  border: 1px solid #ccc;
  border-radius: 0.5rem;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  font-size: 16px;
  color: #333;
  appearance: none;
  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 4 5"><path fill="%23333" d="M2 0L0 2h4zm0 5L0 3h4z"/></svg>');
  background-repeat: no-repeat;
  background-position: right 10px center;
  cursor: pointer;

  &:focus {
    border-color: #007bff;
    outline: none;
    box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
  }
}

To initialize the worker, we will use the useRef and useEffect hooks. We first define a reference for the worker like so:

  const workerRef = useRef(null);

In the useEffect, we create the worker and provide the path to its script file (which we haven't created yet).

useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/data-worker.js", import.meta.url)
    );
    const worker = workerRef.current;

    const handleMessage = (event) => {
      const { type, products: newProducts } = event.data;
      if (type === "generated") {
        setProducts(newProducts);
        setFilteredProducts(newProducts);
        setStatus("idle");
      } else if (type === "filtered") {
        setFilteredProducts(newProducts);
        setStatus("idle");
      }
    };

    worker.addEventListener("message", handleMessage);
    worker.postMessage({ type: "generate" });

    return () => {
      worker.removeEventListener("message", handleMessage);
      worker.terminate();
    };
  }, []);

Here's what's happening in the code:

  • We initialize the worker and set workerRef.current to the new worker instance. Instead of providing a string for the script path, we use a URL object to avoid issues during bundling.

  • We post a message to the worker instructing it to generate products and listen for its responses.

  • The handleMessage function processes messages from the worker. It updates the product lists and status based on the message type.

  • Finally, we ensure to clean up by removing the event listener and terminating the worker when the component unmounts.

We can now add helper functions for resetting and filtering products:

const handleFilterProduct = () => {
    setStatus("filtering");
    workerRef.current.postMessage({ type: "filter", filter, products });
};

const handleReset = () => {
    setStatus("resetting");
    setTimeout(() => {
      setFilter("");
      setFilteredProducts(products);
      setStatus("idle");
    }, 500); // Simulating a short delay for visual feedback
};

The code above does the following:

  • The handleFilterProduct function sends a message to the worker to filter the products. It filters based on the selected filter and current products.

  • The handleReset function resets the products to their initial state and clears any applied filters.

To see what we currently have, navigate to the App.jsx file and add the component:

import "./App.css";
import Products from "./components/Products";

function App() {
  return (
    <>
      <Products />
    </>
  );
}

export default App;

Next, let's focus on the worker code. Create a folder called workers and inside it, add a file named data-worker.js.

In this file, write out the following code:

function getProducts() {
  const products = Array.from({ length: 5000 }, () => ({
    id: Math.random().toString(36).substring(2, 9),
    name: `Product #${Math.floor(Math.random() * 1000)}`,
    category: ["Electronics", "Clothing", "Toys"][
      Math.floor(Math.random() * 3)
    ],
    price: Math.floor(Math.random() * 100),
  }));

  return Promise.resolve(products);
}

The function above generates an array of 5000 products and returns it as a resolved promise. This simulates an asynchronous data-fetching operation.

Still in data-worker.js, add the following code to handle the workers' functionality:

self.addEventListener("message", async (event) => {
  const { type, products, filter } = event.data;

  switch (type) {
    case "generate":
      const generatedProducts = await getProducts();
      self.postMessage({ products: generatedProducts, type: "generated" });
      break;

    case "filter":
      if (products && filter) {
        const filteredProducts = products.filter(
          (prod) => prod.category === filter
        );
        self.postMessage({ products: filteredProducts, type: "filtered" });
      }
      break;

    default:
      break;
  }
});

The code listens for messages from the main thread. It either generates new products or filters the existing ones based on the data received. The results are then sent back to the main thread as a message.

Congratulations on completing the project! You can see the final product in action by visiting the live demo. If you'd like to review the entire codebase or explore further enhancements, you can find the complete source code on GitHub here.

Conclusion

In this guide, we explored the concept of web workers, including their types and how to integrate them into React applications. Workers are valuable for offloading intensive tasks to background threads, enhancing the user experience.

For more information on web workers and related concepts, refer to the MDN Web Docs on Web Workers.