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 web or --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.

Every component, one instance Components call a loader that returns a single shared promise. Whoever arrives first starts the instantiation and the rest await the same result, so exactly one instance exists regardless of mount order. OrderForm PriceWidget Preview loadEngine() one memoised promise one instance, one memory instantiated once per page Without the memoised promise, three components mounting together produce three instances and three heaps — invisible until memory is measured.

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.

The module arrives after the application is usable The application bundle loads and renders first. The module is fetched and instantiated afterwards, and features that depend on it become available when it is ready rather than delaying first paint. app bundle — renders interactive module fetched and instantiated live validation switches on Nothing on the critical path waits for the module, which is what keeps a 97 kB addition from costing anything users notice. Load it earlier — on route entry, on focus of the relevant field — when the feature is central rather than incidental.

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.

One instance, many components Instantiating inside a component means one instance per mount. Loading once at module scope and sharing the promise gives every component the same instance. module scope one init promise await in effect components share it one instance state preserved many components all call the same An instantiation inside a component body runs on every mount and quietly multiplies memory use. Strict mode mounts twice in development, which makes the per-component mistake visible early. Keep the call itself out of render — a synchronous Wasm call in render blocks the commit.

Gotchas

  • An instance per component. Memoise the promise at module scope.
  • await inside a component body. Instantiation belongs in the hook; calls are synchronous.
  • The .wasm inlined 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-pack generates 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.

← Back to Full-Stack Frameworks with Wasm