Integrating Wasm into a React App
This guide answers one task: take an existing React application and add a WebAssembly module for one specific job — validation, parsing, a calculation — without breaking the build, blocking the first paint, or instantiating the module more than once.
Prerequisites
- [ ] A React 18+ application built with Vite, Webpack 5 or Next.js.
- [ ] A module built with
wasm-pack build --target webor--target bundler. - [ ] A clear boundary: one coarse function rather than a chatty interface.
- [ ] A fallback for when the module is unavailable.
One instance, behind a promise
The module must be instantiated once per page, not once per component. The simplest reliable pattern is a module-scope promise that every caller awaits.
// src/wasm/engine.js
let promise = null;
export function loadEngine() {
promise ??= import('./pkg/engine.js').then(async (mod) => {
await mod.default(); // instantiate; wasm-pack's init
return mod;
});
return promise;
}
Assigning the promise rather than the resolved module matters. Two components mounting at the same time
would otherwise each start an instantiation, producing two instances, two copies of linear memory and
two sets of state — a bug that only appears under a race and is unpleasant to find.
A hook that exposes readiness
Components need to know whether the module is available, because it arrives asynchronously and may never arrive at all. A small hook covers every case without each component reimplementing it.
import { useEffect, useState } from 'react';
import { loadEngine } from '../wasm/engine';
export function useEngine() {
const [state, setState] = useState({ status: 'loading', engine: null, error: null });
useEffect(() => {
let cancelled = false;
loadEngine()
.then((engine) => { if (!cancelled) setState({ status: 'ready', engine, error: null }); })
.catch((error) => { if (!cancelled) setState({ status: 'error', engine: null, error }); });
return () => { cancelled = true; };
}, []);
return state;
}
The cancelled flag prevents a state update after unmount, which React warns about and which happens
routinely in development with strict mode double-mounting. Returning an explicit status rather than a
nullable module makes the three cases visible at every call site, which is what stops “it works on my
machine and the spinner never goes away in production”.
Using it without blocking rendering
The module’s functions are synchronous once instantiated, so a component can call them during an event handler or a memo — but not during the first render, when it may not be ready.
function OrderForm({ order }) {
const { status, engine } = useEngine();
const violations = useMemo(() => {
if (status !== 'ready') return null; // server will validate; show nothing yet
return engine.validate_order(JSON.stringify(order));
}, [status, engine, order]);
return (
<form>
{/* fields */}
{violations?.length > 0 && <ViolationList items={violations} />}
{status === 'error' && <p className="hint">Live checks unavailable — we will validate on submit.</p>}
</form>
);
}
Two things to keep out of the render path. Do not call the module on every keystroke without
useMemo or a debounce: even a fast function called sixty times a second competes with rendering. And do
not await inside render — instantiation is asynchronous, calls are not, so the awaiting happens once in
the hook and never again.
Bundler configuration
Vite handles wasm-pack --target web output with almost no configuration; the common failure is the
module being treated as an asset to inline rather than fetched.
// vite.config.js
export default {
optimizeDeps: { exclude: ['./src/wasm/pkg'] }, // do not pre-bundle the glue
build: { target: 'esnext' }, // top-level await in the glue
assetsInlineLimit: 0, // never inline a .wasm as a data URI
};
Webpack 5 needs the experiment flag and an asset rule:
// webpack.config.js
module.exports = {
experiments: { asyncWebAssembly: true },
module: { rules: [{ test: /\.wasm$/, type: 'webassembly/async' }] },
};
With either bundler, verify in the built output rather than in development: the error people hit is a
404 for the .wasm in production, caused by the glue and the binary being emitted to different places or
the binary not being emitted at all.
Passing data across the boundary from React
React state is objects; the module wants bytes. How you bridge that decides most of the per-call cost, and there are three reasonable answers depending on payload size.
For small payloads — a form, a record, a request — JSON is right. JSON.stringify on the way in and a
parse on the way out costs microseconds at these sizes, every language produces and consumes it, and it
is debuggable by eye. Resist optimising this until a measurement complains.
For large or repeated payloads, a typed array avoids the stringify entirely: pack the numbers into a
Float64Array or Int32Array, write it into the module’s memory once, and read the result the same way.
This is the right shape for anything numeric — a table of values, a time series, a set of coordinates.
For objects that cross frequently and barely change, keep them on one side. Rather than sending the whole
document on every keystroke, send the edit and let the module maintain its own copy. That turns an
O(document) crossing into an O(edit) one, which for a large document is the difference between
smooth and unusable.
// small and occasional: JSON is fine
const violations = engine.validate_order(JSON.stringify(order));
// numeric and repeated: no stringify, no parse
const input = new Float64Array(series); // reuse this array across calls
const out = engine.smooth(input, windowSize); // wasm-bindgen copies it in
// large and incremental: send the change, not the state
engine.apply_edit(JSON.stringify({ op: 'insert', at: 41, text: 'x' }));
Whichever shape you use, keep the allocation out of the render path. Allocating a new typed array on every
render defeats the purpose; allocate once with useRef and reuse it, which is one of the few places React
code benefits from thinking about memory at all.
Expected output
A correctly wired integration shows the module fetched separately, after the main bundle, with the right type:
GET /assets/index-8f1a2c.js 42.1 kB application/javascript
GET /assets/engine-6b03d1.js 3.4 kB application/javascript
GET /assets/engine_bg-6b03d1.wasm 96.7 kB application/wasm
console:
engine ready in 38 ms
validate_order: 0.21 ms for 34 fields
If the .wasm appears as a data: URI inside the JavaScript bundle, the inline limit is too high and you
have lost both caching and streaming compilation.
Degrading when it does not load
A module can fail to load: a network error, a blocked request, an old browser, a corporate proxy that mangles the response. The application should keep working.
For validation, the server already validates — that is not optional — so the browser’s copy is an enhancement and its absence costs immediacy rather than correctness. For a calculation, a JavaScript implementation of the same logic is a reasonable fallback if it is small; for something large, disabling the feature with an explanation is more honest than a slow approximation.
if (status === 'error') return <ServerOnlyForm order={order} />;
Test this path deliberately. Block the request in a browser test and assert that the form still submits and still shows server-side errors — the check described in the topic overview — because it is the behaviour nobody exercises by accident.
Gotchas
- An instance per component. Memoise the promise at module scope.
awaitinside a component body. Instantiation belongs in the hook; calls are synchronous.- The
.wasminlined as a data URI. Set the inline limit to zero. - Calling on every keystroke. Debounce or memoise; even a fast call competes with rendering.
- Strict mode double-mounting causing two loads. The memoised promise handles it; a per-effect load does not.
- Types out of sync. If
wasm-packgenerates TypeScript definitions, import them; a hand-written declaration drifts from the module within a release or two.
Performance note
A 97 kB compressed module fetched after first paint, instantiated in 38 ms, and validated a 34-field form in 0.21 ms — against 4.8 ms for the equivalent JavaScript validation with the same rules. The user-visible gain is not the 4.5 ms; it is that the rule set is the same code the server runs, so the two cannot disagree. Performance is rarely the reason to do this, and correctness usually is.
Frequently Asked Questions
Should the module run in a worker? If any single call exceeds a few milliseconds, yes — the interface should never wait on it. For sub-millisecond validation the messaging overhead would exceed the work, and calling directly is simpler.
Can I use it during server-side rendering? Only with care, and the details are framework-specific — see using Wasm in a Next.js project. The usual answer is to skip the module during rendering and load it on the client.
How do I keep the interface and the module in step? Version them together and export a version the loader checks, as with any interface across an artifact boundary. If both come from the same repository and the same build, that is mostly automatic — but check it anyway, because caches outlive deployments.
Related
- Using Wasm in a Next.js project — the same integration with server rendering in the way.
- Sharing validation logic between server and browser — what the module usually contains.
- Bundling Wasm ESM with Vite — the bundler side in detail.
← Back to Full-Stack Frameworks with Wasm