Tutorial · React · Three.js · State
📅 July 2026 ⏱ ≈ 30 min 🎯 Intermediate

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:

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.

When plain Three.js is still the right call: a single self-contained WebGL page with no other React UI around it (most of the simulations on this site) has no need for R3F's reconciler overhead. Reach for R3F when the 3D scene lives inside a larger React application with routing, forms, and shared UI state.

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": meshnew THREE.Mesh(), sphereGeometrynew 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).

Order matters: multiple 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)} />
  );
}
The key distinction: calling the store as a hook (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>
  );
}
Auto-dispose can be disabled per-object with 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:

Next steps: once the state boundaries above feel natural, pair this with the 3D optimisation tutorial for LOD and culling techniques, or the TypeScript for Three.js tutorial to type the refs and store shown here.

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.