跪拜 Guibai
← Back to the summary

The Three-Hook Pattern That Keeps React UIs Responsive During Heavy Computation

React useRef, useEffect, useState and Web Worker Multithreading in Practice

This article explains how three core Hooks work with Web Workers through a "heavy computation without freezing the page" demo.


Problem Background

JavaScript in the browser is single-threaded. If a for loop takes 5 seconds, the page freezes for 5 seconds — buttons become unclickable, and scrolling stutters.

The browser provides the Web Worker API, which can run scripts in a background thread and communicate with the main thread via a message mechanism. To properly manage a Worker's lifecycle in React, useRef + useEffect + useState must work together.

Effect: Click button → button disables and shows "Calculating in background..." → 500,000 iterations run in the Worker → main thread remains smooth → calculation completes, result displays on the page.


Complete Code

Main Component

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

function App() {
    const workerRef = useRef(null);
    const [result, setResult] = useState(null);
    const [loading, setLoading] = useState(false);

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

        worker.onmessage = (e) => {
            const { result } = e.data;
            setResult(result);
            setLoading(false);
        };

        return () => {
            worker.terminate();
            workerRef.current = null;
        };
    }, []);

    const startHeavyCalc = () => {
        setLoading(true);
        workerRef.current.postMessage({ num: 88 });
    };

    return (
        <div style={{ padding: "30px" }}>
            <h2>useRef + WebWorker Heavy Computation</h2>
            <p>Run a 500,000-iteration loop in a Web Worker thread while the main thread stays unblocked</p>
            <button onClick={startHeavyCalc} disabled={loading}>
                {loading ? "Calculating in background...." : "Start Heavy Computation Task"}
            </button>
            {result && <h3>Calculation Result: {result}</h3>}
        </div>
    );
}

export default App;

Worker Script

self.onmessage = (e) => {
    const { num } = e.data;
    let sum = 0;
    for (let i = 0; i < 500000; i++) {
        sum += i * num;
    }
    self.postMessage({ result: sum });
};

Hook Breakdown

useState — Drives the UI

const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
State Purpose When It Changes
loading Button disabled + text switch Click → true; result received → false
result Display calculation result When Worker posts back

loading changes → button grays out, result changes → number appears. useState makes React automatically re-render when data changes.

useEffect — Side Effects and Cleanup

useEffect(() => {
    const worker = new Worker(new URL("./work.js", import.meta.url));
    workerRef.current = worker;
    worker.onmessage = (e) => { /* update state */ };

    return () => {
        worker.terminate();       // Terminate the Worker thread
        workerRef.current = null; // Release the reference
    };
}, []);

useRef — Persistent Reference

const workerRef = useRef(null);

// Assigned in useEffect
workerRef.current = worker;

// Used in event handler
workerRef.current.postMessage({ num: 88 });

Why not a plain variable?

let worker = null;  // ❌ On every re-render, App() re-executes, worker becomes null again

Why not useState?

const [worker, setWorker] = useState(null);  // ❌ A Worker instance doesn't need to trigger renders

useRef returns a { current: ... } object that remains the same object throughout the component's entire lifecycle:

First render → { current: null }
    ↓ useEffect assigns
Second render → { current: Worker instance }  (still the same object!)
Third render → { current: Worker instance }  (still the same)

Two key characteristics:

  1. Persistence — value persists across renders
  2. Mutable without triggering renders — changing .current does not cause a component update

How the Three Collaborate

Hook Role Responsibility
useRef Persistent reference Holds the Worker instance, not lost across renders
useState Reactive state result/loading changes → UI updates
useEffect Side effect management Create/destroy Worker

Web Worker Communication Model

Main Thread                          Worker Thread
─────────────                        ──────────────
postMessage({num:88})  ──────────►  onmessage receives
                                     for loop calculating...
                                     (main thread free to handle UI)
onmessage receives result ◄───────  postMessage({result:...})
setResult() → re-render

Key points:


Data Flow Timeline

Click button
  → setLoading(true)          Button grays out
  → postMessage({num:88})     Send task to Worker
      Worker starts 500k loop  【Main thread free, user operates normally】
      Worker finishes calculation
  ← onmessage fires
  → setResult(result)         Display result
  → setLoading(false)         Button restores

Without a Worker, the loop blocks the main thread — during that time the user can do nothing.


Summary

Concept One-Liner
useState Data changes notify React to re-render
useEffect Run side effects after render, and handle cleanup
useRef Persistent reference across renders, mutating it does not trigger a render
Web Worker The browser's true multithreading, communicating via messages
postMessage/onmessage The "walkie-talkie" between main thread and Worker

Standard pattern: useRef holds the reference → useEffect manages creation/destruction → useState drives the UI.