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
};
}, []);
- Why inside useEffect:
new Worker(...)is a side effect — it operates on the browser environment, not computing UI. The render function should be pure; side effects must go in useEffect. - Why return a cleanup function: When the component unmounts, the Worker does not auto-destroy. Without
terminate(), you get a memory leak. []empty dependency: Executes once on mount, cleans up once on unmount. Analogous tocomponentDidMount+componentWillUnmount.
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:
- Persistence — value persists across renders
- Mutable without triggering renders — changing
.currentdoes 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 |
- Without useRef → Worker instance is lost on re-render
- Without useState → Worker returns a result but the UI doesn't move
- Without useEffect → Worker creation timing is uncontrollable, cleanup has no place
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:
- Worker and main thread are truly concurrent — running on independent OS threads
- Communication relies on message passing, not shared memory
- Workers cannot manipulate the DOM, have no
window/document, and the global object is calledself import.meta.urlwithnew URL()ensures Vite correctly handles the Worker file's bundling path
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.