Three.js + React: Integration & State Management
React wants to own the DOM through a declarative virtual tree.
Three.js wants direct, imperative control of a WebGL scene graph
running 60 times a second. Bolt them together the naive way — a
useEffect that builds a renderer and a
useState that drives every animation frame — and you
get either a memory leak or a component that re-renders itself
into the ground. react-three-fiber reconciles the
two models properly, and this tutorial covers the parts that
actually matter: the render loop, refs, and where simulation state
should live.
1. Why not just useEffect + plain Three.js
It's tempting to treat Three.js as an escape hatch: mount a
<canvas> ref, build a renderer/scene/camera
inside a single useEffect, and run your own
requestAnimationFrame loop entirely outside React.
This works for a single static demo, but it throws away
everything React is good at:
- No declarative composition — adding/removing objects means manually tracking what's already in the scene and diffing it yourself.
-
No conditional rendering — showing/hiding a
mesh based on props means writing your own
scene.add/scene.removebookkeeping instead of a plain{condition && <Mesh />}. - Stale closures — the render loop captures whatever props/state existed when the effect ran; keeping it in sync with fast-changing React state needs manual refs anyway.
react-three-fiber (R3F) is a React renderer for
Three.js — not a wrapper library, an actual custom reconciler like
React DOM. JSX elements map 1:1 to Three.js classes
(<mesh> → THREE.Mesh,
<boxGeometry> →
THREE.BoxGeometry), so you get real component
composition, context, suspense, and hooks — while R3F handles
scene graph mutations behind the scenes.
2. Setting up react-three-fiber
npm install three @react-three/fiber @react-three/drei
<Canvas> creates the renderer, scene, default
camera, and starts the render loop. Everything inside it is
Three.js JSX:
// App.jsx
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
export default function App() {
return (
<Canvas camera={{ position: [0, 2, 6], fov: 50 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[3, 5, 2]} intensity={1} />
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="#60a5fa" />
</mesh>
<OrbitControls />
</Canvas>
);
}
Lowercase JSX tags (<mesh>,
<sphereGeometry>) are R3F's convention for
"any Three.js class, resolved from its name": mesh
→ new THREE.Mesh(),
sphereGeometry → new THREE.SphereGeometry().
Props map to constructor arguments (args) or direct
property assignment (color,
position, intensity).
3. Animate with useFrame, not setState
The single most important rule in R3F: never drive a
per-frame animation with useState. Calling
setState 60 times a second forces React to
re-render the component tree 60 times a second — exactly the
overhead React is supposed to help you avoid.
// ❌ Don't do this — re-renders React every frame
function SpinningBox() {
const [rotation, setRotation] = useState(0);
useEffect(() => {
let raf;
const tick = () => {
setRotation((r) => r + 0.01); // triggers React re-render
raf = requestAnimationFrame(tick);
};
tick();
return () => cancelAnimationFrame(raf);
}, []);
return <mesh rotation-y={rotation}>...</mesh>;
}
// ✅ Do this — mutates the Three.js object directly, no React render
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function SpinningBox() {
const meshRef = useRef(null);
useFrame((state, delta) => {
meshRef.current.rotation.y += delta; // direct mutation, ~free
});
return (
<mesh ref={meshRef}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
);
}
useFrame registers a callback on R3F's internal
render loop (a single shared requestAnimationFrame
for the whole tree, not one per component). It receives
delta (seconds since last frame) so motion stays
frame-rate independent, and the shared state object
(clock, camera, scene, gl, pointer, viewport).
useFrame
callbacks across the tree run in registration order by default.
Pass a numeric priority as a second argument
(useFrame(cb, 1)) to force a callback to run after
others — useful when one component's output feeds another's
input within the same frame.
4. Refs for imperative access
useRef is how you reach past R3F's declarative JSX
to touch the underlying Three.js instance directly — required for
anything useFrame mutates, plus raycasting,
manual geometry updates, or calling methods that have no prop
equivalent.
function Particles({ count }) {
const instancedRef = useRef(null);
const dummy = useMemo(() => new THREE.Object3D(), []);
useFrame(() => {
const mesh = instancedRef.current;
if (!mesh) return;
for (let i = 0; i < count; i++) {
dummy.position.set(
Math.sin(i + performance.now() * 0.0005) * 3,
0,
Math.cos(i + performance.now() * 0.0005) * 3
);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={instancedRef} args={[undefined, undefined, count]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial color="#60a5fa" />
</instancedMesh>
);
}
Note the args prop on <instancedMesh>:
whenever a Three.js constructor takes positional arguments,
args is how R3F passes them. Changing an
args array forces R3F to dispose the old instance and
construct a fresh one — useful to know when a prop unexpectedly
"resets" your object.
5. Shared simulation state with Zustand
When multiple components need to read or write the same
fast-changing values (particle count, simulation speed, a
selected object) useState lifted to a common parent
still triggers a React re-render on every update — the same
problem as section 3, just spread across components.
Zustand stores state outside React entirely;
components subscribe only to the specific slice they render, and
useFrame callbacks can read/write the store directly
without subscribing (and therefore without causing any React
re-render at all).
// store.js
import { create } from 'zustand';
export const useSimStore = create((set, get) => ({
speed: 1,
particleCount: 2000,
setSpeed: (speed) => set({ speed }),
}));
// Inside useFrame — read the LATEST value without subscribing/re-rendering
useFrame((_, delta) => {
const speed = useSimStore.getState().speed; // .getState(), not the hook
meshRef.current.rotation.y += delta * speed;
});
// Inside a UI component — this DOES subscribe, re-renders on change (fine, it's a slider label)
function SpeedSlider() {
const speed = useSimStore((s) => s.speed);
const setSpeed = useSimStore((s) => s.setSpeed);
return (
<input type="range" min="0" max="3" step="0.1"
value={speed} onChange={(e) => setSpeed(+e.target.value)} />
);
}
useSimStore((s) => s.speed)) subscribes the
component and re-renders it on change — correct for UI. Calling
useSimStore.getState() inside useFrame
reads the current value with zero subscription and zero
re-render — correct for the animation loop. Mixing them up in
either direction is the most common R3F performance bug.
6. React state is for UI, not the scene
A useful mental model: draw a line between "scene state" (position,
rotation, per-frame physics — changes every frame, read by
useFrame) and "UI state" (is the settings panel open,
which preset is selected, a debounced slider value — changes
occasionally, needs to re-render JSX).
| Kind of state | Where it lives | How it's read |
|---|---|---|
| Per-frame transform/physics | Refs, mutated in useFrame |
Direct property mutation |
| Cross-component shared sim params | Zustand (or Valtio/Jotai) | getState() in loop, hook in UI |
| UI-only toggles/menus/labels | useState/props |
Normal React re-render |
It's fine — good, even — for useState to re-render
the tree when the user opens a panel or picks a dropdown option.
The rule is narrower than "avoid React state": it's specifically
"don't let React re-renders gate anything that has to happen 60
times a second."
7. Cleanup and disposal
R3F automatically disposes geometries and materials created via
JSX when a component unmounts — one of its biggest practical
advantages over hand-rolled Three.js. Manually created resources
(loaded textures, custom buffers you build in
useMemo) still need explicit disposal:
function TexturedPlane({ url }) {
const texture = useMemo(() => new THREE.TextureLoader().load(url), [url]);
useEffect(() => {
return () => texture.dispose(); // runs on unmount or when `url` changes
}, [texture]);
return (
<mesh>
<planeGeometry args={[4, 4]} />
<meshBasicMaterial map={texture} />
</mesh>
);
}
dispose={null} on a JSX element — useful when you
share one geometry/material instance across many meshes and don't
want R3F destroying it when just one of them unmounts.
8. Performance patterns that still apply
Every optimisation technique from plain Three.js carries over to R3F unchanged — it's the same renderer underneath:
-
<instancedMesh>for large repeated objects (see the refs example above) — one draw call instead of thousands. -
frameloop="demand"on<Canvas>— stops the render loop entirely when nothing has changed (static diagrams, paused simulations), and callinvalidate()to request a single re-render on demand. -
<Suspense>+ drei'suseGLTF/useTextureloaders — assets load asynchronously and the scene mounts only once they're ready, with a fallback in the meantime. -
React.memoon leaf components that receive stable props — same rules as any other React tree, since R3F components are still React components.
Frequently Asked Questions
What will I learn in this tutorial?
Wire Three.js into a React app the right way: react-three-fiber's declarative scene graph, the useFrame render loop, refs for imperative objects, Zustand for simulation state outside React's render cycle, and cleanup on unmount.
What topics are covered in this tutorial?
This tutorial covers: Why not just useEffect + plain Three.js, Setting up react-three-fiber, Animate with useFrame, not setState, Refs for imperative access, Shared simulation state with Zustand, React state is for UI, not the scene, Cleanup and disposal, Performance patterns that still apply.
How long does this tutorial take?
This tutorial takes approximately 30 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.