How to Initialize Ammo.js in React useEffect

Integrating the asynchronous ammo.js physics engine into a React application requires careful lifecycle management to prevent memory leaks, redundant initializations, and race conditions. This article provides a straightforward guide and a production-ready pattern for safely initializing ammo.js inside a React useEffect hook, handling React’s Strict Mode, and sharing the initialized physics instance with your components.

The Challenge with Ammo.js in React

ammo.js is a WebAssembly (WASM) port of the Bullet Physics engine. Because WASM modules load asynchronously, Ammo() returns a Promise (or a promise-like object) that resolves once the runtime is fully compiled and ready.

If you initialize ammo.js directly inside a React component without proper tracking, React’s Strict Mode (which mounts components twice in development) can trigger multiple instances of the physics engine. This leads to leaked memory and unexpected behavior.

The best practice is to load the library asynchronously inside useEffect, track the loading state to prevent setting state on an unmounted component, and store the initialized Ammo instance in a React state or context.

Here is the clean, robust implementation pattern:

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

export default function PhysicsContainer() {
  const [ammoInstance, setAmmoInstance] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;

    // Check if Ammo is already available on the window object
    // or import it if you are using an npm wrapper
    const initializeAmmo = async () => {
      try {
        if (typeof window !== 'undefined' && window.Ammo) {
          // Initialize Ammo if loaded via script tag
          const AmmoModule = await window.Ammo();
          if (isMounted) {
            setAmmoInstance(AmmoModule);
            setLoading(false);
          }
        } else {
          // If using an npm package like 'ammojs-typed' or similar
          const AmmoLib = await import('ammo.js');
          const AmmoModule = await AmmoLib.default();
          if (isMounted) {
            setAmmoInstance(AmmoModule);
            setLoading(false);
          }
        }
      } catch (err) {
        if (isMounted) {
          setError(err);
          setLoading(false);
        }
      }
    };

    initializeAmmo();

    // Cleanup function to prevent state updates on unmounted components
    return () => {
      isMounted = false;
    };
  }, []);

  if (loading) return <div>Loading Physics Engine...</div>;
  if (error) return <div>Error loading physics: {error.message}</div>;

  return (
    <div>
      <p>Physics Engine Status: Ready</p>
      {/* Pass ammoInstance to child components or context */}
    </div>
  );
}

Key Breakdown of the Implementation

1. The Cleanup Flag (isMounted)

The boolean flag isMounted is critical. If your component unmounts before the asynchronous Ammo() compilation finishes, setting state (setAmmoInstance) will trigger a React memory leak warning. Setting isMounted = false in the cleanup function prevents this.

2. Handling the Promise

Because Ammo() is a callable function returning a promise, wrapping the invocation in an async/await structure ensures JavaScript pauses execution until the WebAssembly module is fully compiled and ready to instantiate.

3. Avoiding Double Initialization

If your project uses React Strict Mode, the component will mount, unmount, and remount instantly. By checking if the instance is already in local state, or by abstracting the initialization to a singleton provider (like React Context), you ensure you do not initialize the heavy WebAssembly module multiple times.