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