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.

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
LightControlbeing 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
- Use
React.memoto prevent child components from re-rendering when sibling state changes - Use
useRefto maintain references to Three.js objects across renders - Separate initialization from updates - create objects in one
useEffect, update them in another - Isolate controls from the canvas to avoid event conflicts
The pattern is similar to D3 with React
Comments
Recent Work
Basalt
basalt.softwareFree 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.
BidBear
bidbear.ioBidbear 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.