Sync React State with Matter.js Without Re-renders

Integrating real-time 2D physics engines like Matter.js into React requires bridging two fundamentally different paradigms: React's reactive render cycle and Matter.js's continuous 60 FPS mutation loop. Storing body transforms (such as position, angle, and velocity) in standard React state triggers component re-renders on every physics tick, causing severe performance degradation. You can achieve smooth, bidirectional synchronization between dynamic React values and Matter.js body transforms without re-rendering by leveraging mutable React references (useRef), decoupled game loops, and direct DOM or Canvas updates.

The Problem with React State in Physics Loops

React's useState schedules a component re-render every time its setter is called. A standard physics engine updates at 60 Hz or higher. If a body's position.x and position.y are copied to React state inside a tick event, React attempts to re-render the component tree 60 times per second per object. This introduces reconciliation overhead, frame drops, and input lag.

To maintain peak performance, the physics simulation and the visual representation must bypass the React reconciliation lifecycle entirely.

The Solution: The Mutable Reference Pattern

The core technique relies on React’s useRef hook. A ref persists across renders without triggering a re-render when its .current property is mutated.

You can handle synchronization in two directions:

  1. React to Matter.js: Pass dynamic parameters (e.g., user inputs, scale, wind, target positions) into the physics simulation.
  2. Matter.js to UI: Reflect body transforms onto the screen (DOM or Canvas) using direct mutations.

1. Passing React Values to Matter.js via Refs

When an external React state changes (such as a slider controlling gravity or user mouse coordinates), mirror that value into a ref. The physics loop reads from this ref on every tick without requiring the component running the physics simulation to re-render.

import React, { useEffect, useRef, useState } from 'react';
import Matter from 'matter-js';

export const PhysicsContainer = () => {
  const [speedMultiplier, setSpeedMultiplier] = useState(1);

  // Store dynamic state in a ref for the physics loop
  const speedRef = useRef(speedMultiplier);

  useEffect(() => {
    speedRef.current = speedMultiplier;
  }, [speedMultiplier]);

  // Physics engine setup continues below...

2. Updating Matter.js Transforms from Refs

Hook into the Matter.js update cycle using Matter.Events.on(engine, 'beforeUpdate', callback). Inside this hook, apply your transformations directly to the target Matter.Body.

useEffect(() => {
  const { Engine, Bodies, Composite, Events } = Matter;
  const engine = Engine.create();
  const box = Bodies.rectangle(100, 100, 50, 50);
  Composite.add(engine.world, box);

  // Listen to engine updates to inject values without re-renders
  Events.on(engine, 'beforeUpdate', () => {
    // Read the latest state from the ref safely
    const currentSpeed = speedRef.current;
    
    // Apply transforms directly to the body
    Matter.Body.setVelocity(box, {
      x: box.velocity.x * currentSpeed,
      y: box.velocity.y
    });
  });

  // Start engine runner...
}, []);

3. Rendering Body Transforms Without Component Updates

If you are rendering your physics bodies using HTML/DOM elements instead of the built-in Matter.Render canvas, synchronize the DOM directly via element refs rather than React state.

import React, { useEffect, useRef } from 'react';
import Matter from 'matter-js';

export const DOMPhysicsBox = () => {
  const domElementRef = useRef(null);
  const engineRef = useRef(Matter.Engine.create());

  useEffect(() => {
    const { Engine, Runner, Bodies, Composite, Events } = Matter;
    const engine = engineRef.current;
    const body = Bodies.rectangle(200, 200, 60, 60);

    Composite.add(engine.world, body);

    const runner = Runner.create();
    Runner.run(runner, engine);

    // Sync transforms directly to the DOM node per frame
    Events.on(engine, 'afterUpdate', () => {
      if (!domElementRef.current) return;

      const { x, y } = body.position;
      const angle = body.angle;

      // Direct mutation bypasses React reconciliation entirely
      domElementRef.current.style.transform = 
        `translate3d(${x - 30}px, ${y - 30}px, 0px) rotate(${angle}rad)`;
    });

    return () => {
      Runner.stop(runner);
      Engine.clear(engine);
    };
  }, []);

  return (
    <div
      ref={domElementRef}
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        width: 60,
        height: 60,
        backgroundColor: 'royalblue',
        willChange: 'transform',
      }}
    />
  );
};

Bidirectional Sync Strategies