Skip to main content

Three.js with React

Intro

Three.js and React have a fundamental compatibility challenge similar to D3 with React. Both libraries manipulate the DOM directly - Three.js renders to a canvas element while React manages a Virtual DOM. When building interactive 3D scenes, you'll often want UI controls (sliders, color pickers, toggles) that modify Three.js objects. The naive approach to wiring up these controls causes a frustrating issue: dragging sliders becomes impossible because every state change triggers component re-renders that interrupt the interaction.

lighting controls on 3D phone

The Issue: Re-rendering Breaks Interactions

Consider a component with multiple lighting controls:

function Scene3D() {
const [mainLight, setMainLight] = useState({
intensity: 0.8,
position: {x: 5, y: 5, z: 5},
});
const [backLight, setBackLight] = useState({
intensity: 0.3,
position: {x: -5, y: 0, z: -5},
});
const [ambientLight, setAmbientLight] = useState({intensity: 0.6});

return (
<div>
<canvas ref={canvasRef} />
<LightControl config={mainLight} onChange={setMainLight} />
<LightControl config={backLight} onChange={setBackLight} />
<LightControl config={ambientLight} onChange={setAmbientLight} />
</div>
);
}

When you drag a slider in LightControl, each state update (which happens continuously during drag) causes the parent component to re-render. This re-renders ALL LightControl components, interrupting the drag gesture. The slider moves one increment and stops.

The Solution: React.memo

Wrap your control components with React.memo to prevent unnecessary re-renders:

import {memo} from 'react';

const LightControl = memo(({label, config, onChange}) => {
return (
<div className="control-section">
<h3>{label}</h3>

<div className="control-row">
<label>Intensity:</label>
<input
type="range"
min={0}
max={2}
step={0.1}
value={config.intensity}
onChange={(e) =>
onChange({...config, intensity: parseFloat(e.target.value)})
}
/>
<span>{config.intensity.toFixed(1)}</span>
</div>

{config.position && (
<>
<div className="control-row">
<label>Position X:</label>
<input
type="range"
min={-10}
max={10}
step={0.5}
value={config.position.x}
onChange={(e) =>
onChange({
...config,
position: {...config.position, x: parseFloat(e.target.value)},
})
}
/>
<span>{config.position.x.toFixed(1)}</span>
</div>
</>
)}
</div>
);
});

LightControl.displayName = 'LightControl';

Now when you drag a slider:

  • Only the specific LightControl being modified re-renders
  • Other controls remain untouched
  • The drag gesture continues smoothly

Updating Three.js Objects

Use useEffect with refs to update Three.js objects when state changes:

function Scene3D() {
const sceneRef = useRef(null);
const lightsRef = useRef({});

const [mainLight, setMainLight] = useState({
intensity: 0.8,
position: {x: 5, y: 5, z: 5},
});

useEffect(() => {
// Initial setup
const scene = new THREE.Scene();
sceneRef.current = scene;

const light = new THREE.DirectionalLight(0xffffff, 0.8);
light.position.set(5, 5, 5);
scene.add(light);
lightsRef.current.main = light;

// ... renderer, camera, controls setup
}, []);

useEffect(() => {
// Update lights when state changes
const scene = sceneRef.current;
const lights = lightsRef.current;

if (!scene || !lights.main) return;

lights.main.intensity = mainLight.intensity;
lights.main.position.set(
mainLight.position.x,
mainLight.position.y,
mainLight.position.z,
);
}, [mainLight]);

return (
<div>
<canvas ref={canvasRef} />
<LightControl config={mainLight} onChange={setMainLight} />
</div>
);
}

Avoiding Canvas Event Conflicts

If controls overlay the canvas, OrbitControls may capture events. Two solutions:

Option 1: Separate sidebar

.wrapper {
display: flex;
}

.canvas-container {
flex: 1;
}

.controls-sidebar {
width: 340px;
background: #f8f9fa;
}

Option 2: Disable controls on hover

<div
className="overlay-controls"
onPointerEnter={() => (orbitControls.enabled = false)}
onPointerLeave={() => (orbitControls.enabled = true)}>
{/* controls */}
</div>

Key Takeaways

  1. Use React.memo to prevent child components from re-rendering when sibling state changes
  2. Use useRef to maintain references to Three.js objects across renders
  3. Separate initialization from updates - create objects in one useEffect, update them in another
  4. Isolate controls from the canvas to avoid event conflicts

The pattern is similar to D3 with React

Comments

Recent Work

Free desktop AI Chat client, designed for developers and businesses. Unlocks advanced model settings only available in the API. Includes quality of life features like custom syntax highlighting.

Learn More

BidBear

bidbear.io

Bidbear is a report automation tool. It downloads Amazon Seller and Advertising reports, daily, to a private database. It then merges and formats the data into beautiful, on demand, exportable performance reports.

Learn More