React 124 ⚛️ 3D Web Graphics with React Three Fiber
React Three Fiber is a React renderer for three.js, not a wrapper library. It does not add an abstraction layer between React and the 3D scene; it replaces the DOM as the rendering target. The component tree you write is the scene graph, and mounting a <mesh> creates a THREE.Mesh that is added to the scene. The reconciler is the same one that renders your web UI, which means state, context, refs, and Suspense work in 3D exactly as they do in the DOM .
This chapter covers the core concepts of R3F: the Canvas and the scene graph, the declarative model that maps JSX elements to three.js objects, and the animation pattern that separates structure from per-frame mutation. It addresses the performance pitfalls that make an R3F scene slow—animating through state, creating objects in the render body, and missing instancing—and the ecosystem that provides helpers for loading, controls, and post-processing. The final sections cover the WebGPU transition and the cases where R3F is the wrong choice for a 3D project.
Key point: The React tree is the scene graph. Mounting a component adds an object to the scene; unmounting removes and disposes it. The split that matters is refs for mutation and state for structure. Animating through state is the single most common way an R3F scene becomes slow.
Why React Three Fiber exists
The imperative scene graph problem. three.js is an imperative API. You create a scene, create a mesh, create a geometry and a material, assign them to the mesh, add the mesh to a group, add the group to the scene, and then manage the lifecycle of every object. In a React application, that imperative management conflicts with the component model. R3F resolves the conflict by letting the component tree describe the scene, and the renderer handles the creation and attachment of the underlying three.js objects .
The state problem. A 3D scene that reacts to application state—a selected product, a hovered node, a filtered dataset—needs to update when that state changes. In plain three.js, the application must imperatively traverse the scene and update the relevant objects. In R3F, the scene is a function of state. When the state changes, the component re-renders, and the scene updates. The reactivity that React provides for the DOM applies to the 3D scene without additional plumbing .
The ecosystem problem. A 3D application needs model loading, camera controls, environment maps, post-processing, physics, and debugging tools. The @react-three/drei library provides these as React components and hooks: useGLTF for model loading, OrbitControls for camera navigation, Environment for lighting, and Html for DOM overlays anchored to 3D positions . The ecosystem is the practical reason R3F is the default choice for React 3D applications.
The performance concern. The common misconception is that R3F adds overhead because it routes 3D through React. The documentation states the opposite: components render outside of React, and there is no overhead. The reconciler schedules work, and the frame loop is independent of React’s render cycle. The performance depends on how the developer uses the tools, not on the renderer itself .
a. The Canvas, the scene graph, and the declarative model
The <Canvas> component creates the renderer, the scene, and a default perspective camera. It sets up the render loop, handles window resizing, and establishes the context that every R3F hook reads. Inside the Canvas, three.js objects are available as JSX elements under their camel-case names: <mesh>, <boxGeometry>, <meshStandardMaterial>, <ambientLight> .
import { Canvas } from '@react-three/fiber';
function App() {
return (
<Canvas camera={{ position: [0, 0, 5], fov: 75 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
</Canvas>
);
}
The <mesh> element creates a THREE.Mesh. The <boxGeometry> creates a THREE.BoxGeometry and attaches it to the mesh’s geometry property. The <meshStandardMaterial> creates a material and attaches it to the material property. The attachment is inferred from the element name, or specified explicitly with the attach prop .
Constructor arguments are passed as an array via the args prop. <boxGeometry args={[1, 1, 1]} /> is equivalent to new THREE.BoxGeometry(1, 1, 1). If args changes, the object is reconstructed from scratch. Properties are passed as props: <mesh position={[1, 2, 3]} /> calls mesh.position.set(1, 2, 3) on the underlying Vector3 .
The declarative model has one rule that is easy to violate: do not create three.js objects in the render body. The following is wrong because it creates a new geometry and material on every render:
// ❌ Creates new objects on every render.
<mesh geometry={new THREE.SphereGeometry(1, 16, 16)} material={new THREE.MeshBasicMaterial({ color: 'hotpink' })} />
The correct form is declarative, with the geometry and material as child elements:
// ✅ The objects are created once and attached.
<mesh>
<sphereGeometry args={[1, 16, 16]} />
<meshBasicMaterial color="hotpink" />
</mesh>
b. Animation, refs, and the frame loop
The useFrame hook registers a callback that runs before every render. It receives the state and the delta time since the last frame. The callback is the place for per-frame mutation: rotation, position updates, material changes that depend on time .
The pattern is to hold a ref to the mesh and mutate its properties inside the callback. The component does not re-render, and React’s scheduler is not involved in the animation.
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
function RotatingBox() {
const meshRef = useRef();
useFrame((state, delta) => {
// Mutate the ref, not state.
meshRef.current.rotation.x += delta;
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
);
}
The delta multiplication is not optional. A fixed increment like rotation.x += 0.01 makes the animation speed depend on the frame rate. On a 120 Hz display, the box rotates twice as fast as on a 60 Hz display. Multiplying by delta makes the animation frame-rate independent .
The anti-pattern is using state to drive per-frame animation. The following is slow because every frame triggers a React render, a reconciliation pass, and a prop update:
// ❌ setState in useFrame causes a render every frame.
const [x, setX] = useState(0);
useFrame(() => setX((prev) => prev + 0.01));
return <mesh position-x={x} />;
The correct pattern is the ref mutation above. The rule is: refs for mutation, state for structure. Position, rotation, and scale change every frame and belong to refs. Which objects exist, and whether an object is selected, are structure and belong to state .
The same rule applies to fast events. A pointer move that updates a mesh position should mutate the ref, not set state. A hover that changes a color can use state, because the change happens once per hover, not per frame .
c. Performance, instancing, and the ecosystem
The performance of an R3F scene is determined by the number of draw calls. Each <mesh> with a unique geometry and material is a draw call. A scene with a thousand individual meshes has a thousand draw calls, and the GPU spends more time on overhead than on rendering.
The solution is instancing. An <instancedMesh> renders many copies of the same geometry and material in a single draw call. The per-instance transforms are stored in a matrix buffer, and the developer updates that buffer inside useFrame. The @react-three/drei library provides an <Instances> and <Instance> wrapper that makes the pattern declarative for static or prop-driven instances .
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
function Particles({ count = 2000 }) {
const meshRef = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
useFrame((state) => {
const t = state.clock.elapsedTime;
for (let i = 0; i < count; i++) {
// Compose a matrix for each instance.
dummy.position.set(Math.sin(i + t) * 5, Math.cos(i + t) * 5, 0);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
// Mark the buffer for upload once per frame.
meshRef.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshStandardMaterial color="orange" />
</instancedMesh>
);
}
The dummy object is a scratch Object3D used to compose the matrix. It is allocated once and rewritten every frame. The needsUpdate flag is set once after the loop, not per instance .
The ecosystem that surrounds R3F provides the rest of the production needs. @react-three/drei includes useGLTF for loading GLTF models with Suspense integration, OrbitControls for camera navigation, Environment for HDR lighting, Html for DOM overlays, Text for 3D text, and Detailed for level-of-detail swapping based on camera distance . @react-three/postprocessing provides effect composers, @react-three/rapier provides physics, and @react-three/xr provides VR and AR support .
Model loading follows the Suspense pattern. The useGLTF hook throws a promise while the model loads, and a <Suspense> boundary catches it and renders a fallback. The useGLTF.preload function starts the load before the component renders, which removes the wait when the component mounts .
import { Suspense } from 'react';
import { useGLTF } from '@react-three/drei';
function Model() {
const { scene } = useGLTF('/model.glb');
return <primitive object={scene} />;
}
function App() {
return (
<Canvas>
<Suspense fallback={null}>
<Model />
</Suspense>
</Canvas>
);
}
// Start loading before the component mounts.
useGLTF.preload('/model.glb');
Complete Example Session
// ============================================
// PART 1: THE CANVAS
// ============================================
// The renderer, scene, and camera are created by Canvas.
import { Canvas } from '@react-three/fiber';
export default function App() {
return (
<Canvas camera={{ position: [0, 0, 8], fov: 50 }}>
<Scene />
</Canvas>
);
}
// ============================================
// PART 2: THE SCENE
// ============================================
// The component tree is the scene graph.
function Scene() {
return (
<>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 10, 5]} castShadow />
<RotatingBox position={[-2, 0, 0]} />
<RotatingBox position={[2, 0, 0]} />
<Particles />
</>
);
}
// ============================================
// PART 3: THE ANIMATED MESH
// ============================================
// ref + useFrame for per-frame mutation.
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
function RotatingBox({ position }) {
const ref = useRef();
useFrame((state, delta) => {
ref.current.rotation.x += delta;
ref.current.rotation.y += delta * 0.5;
});
return (
<mesh ref={ref} position={position} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
);
}
// ============================================
// PART 4: THE INSTANCED PARTICLES
// ============================================
// One draw call for many instances.
import { useMemo } from 'react';
import * as THREE from 'three';
function Particles({ count = 1000 }) {
const ref = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
useFrame((state) => {
const t = state.clock.elapsedTime;
for (let i = 0; i < count; i++) {
dummy.position.set(
Math.sin(i * 0.1 + t) * 4,
Math.cos(i * 0.1 + t) * 4,
Math.sin(i * 0.05 + t * 0.5) * 2
);
dummy.updateMatrix();
ref.current.setMatrixAt(i, dummy.matrix);
}
ref.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial color="skyblue" />
</instancedMesh>
);
}
// ============================================
// PART 5: THE MODEL LOADING
// ============================================
// Suspense + useGLTF for async assets.
import { Suspense } from 'react';
import { useGLTF } from '@react-three/drei';
function Model({ url }) {
const { scene } = useGLTF(url);
return <primitive object={scene} />;
}
function SceneWithModel() {
return (
<Suspense fallback={null}>
<Model url="/model.glb" />
</Suspense>
);
}
// ============================================
// PART 6: THE CONTROLS
// ============================================
// OrbitControls from drei for camera navigation.
import { OrbitControls } from '@react-three/drei';
function Controls() {
return <OrbitControls enableDamping />;
}
// ============================================
// PART 7: THE POINTER EVENT
// ============================================
// onPointer props are raycast against the mesh.
function ClickableBox() {
const ref = useRef();
const [hovered, setHovered] = useState(false);
return (
<mesh
ref={ref}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
onClick={() => console.log('clicked')}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={hovered ? 'hotpink' : 'orange'} />
</mesh>
);
}
// ============================================
// PART 8: THE STATE VS REF SPLIT
// ============================================
// Structure uses state. Animation uses refs.
function SelectedBox() {
const [selected, setSelected] = useState(false);
const ref = useRef();
useFrame((state, delta) => {
// Animation: mutate the ref.
ref.current.rotation.y += delta;
});
return (
<mesh
ref={ref}
scale={selected ? 1.5 : 1} // Structure: state drives the scale.
onClick={() => setSelected(!selected)}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={selected ? 'gold' : 'orange'} />
</mesh>
);
}
// ============================================
// PART 9: THE PERFORMANCE RULE
// ============================================
// Do not setState inside useFrame.
// Do not create objects in the render body.
// Use instancing for repeated geometry.
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The React tree is the scene graph.
// Refs mutate, state structures.
// The ecosystem fills the gaps.
export default function Root() {
return (
<Canvas>
<Scene />
<Controls />
</Canvas>
);
}
The ten parts build a complete R3F scene: the Canvas, the scene graph, the animated mesh, the instanced particles, the model loading, the controls, and the pointer events. The pattern is consistent: the component tree describes the scene, refs mutate the per-frame properties, and state controls the structure.
Quick Reference
Core Hooks and Components
| API | Purpose |
|---|---|
<Canvas> | Creates renderer, scene, camera, render loop |
useFrame(cb) | Per-frame callback, mutate refs |
useThree() | Access renderer, scene, camera |
useLoader(loader, url) | Load assets with Suspense |
<mesh> | Creates THREE.Mesh |
<boxGeometry args={[...]}> | Creates geometry with constructor args |
<meshStandardMaterial> | Creates material |
The Ref vs State Rule
| Use Case | Mechanism |
|---|---|
| Per-frame animation | ref + useFrame |
| Position, rotation, scale | ref mutation |
| Which objects exist | state |
| Selected, hovered, filtered | state |
| Fast pointer events | ref mutation |
Performance Patterns
| Pattern | When to Use |
|---|---|
instancedMesh | Many identical objects |
useGLTF.preload | Model loading before mount |
<Detailed> (drei) | Level of detail by distance |
frameloop="demand" | Mostly static scenes |
| Merge geometries | Many different static meshes |
Ecosystem Libraries
| Library | Purpose |
|---|---|
@react-three/drei | Helpers, controls, loaders, text |
@react-three/postprocessing | Effect composers |
@react-three/rapier | Physics |
@react-three/xr | VR/AR |
@react-three/gltfjsx | GLTF to JSX components |
Best Practices
✅ Do This:
// Use refs for per-frame animation.
useFrame((state, delta) => ref.current.rotation.x += delta); // ✅
// Use delta for frame-rate independence.
ref.current.position.x += delta; // ✅
// Use instancing for repeated geometry.
<instancedMesh args={[undefined, undefined, count]}> // ✅
// Preload models before mount.
useGLTF.preload('/model.glb'); // ✅
// Use Suspense for async assets.
<Suspense fallback={null}><Model /></Suspense> // ✅
❌ Don’t Do This:
// Don't setState in useFrame.
useFrame(() => setX((x) => x + 0.1)); // ❌
// Don't create objects in the render body.
<mesh geometry={new THREE.BoxGeometry()} /> // ❌
// Don't use a fixed increment.
ref.current.rotation.x += 0.01; // frame-rate dependent // ❌
// Don't render 1000 individual meshes.
{items.map((i) => <mesh key={i}><boxGeometry /></mesh>)} // ❌
// Don't mutate a memoized array in place.
position[0] = x; // reference unchanged, R3F skips the update // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Slow animation | setState in useFrame triggers a render every frame | Mutate a ref instead |
| Animation speed varies | Fixed increment without delta | Multiply by delta |
| Objects recreated | Geometry or material in the render body | Use declarative child elements |
| Too many draw calls | Individual meshes for repeated objects | Use instancedMesh |
| Prop update ignored | Memoized array or vector mutated in place | Pass a new value or use dash-case props |
| Model not loading | Suspense boundary missing | Wrap useGLTF in <Suspense> |
Real-World Examples
1. Canvas Setup
<Canvas camera={{ position: [0, 0, 5] }} shadows>
2. Animated Mesh
useFrame((state, delta) => ref.current.rotation.y += delta);
3. Instanced Particles
<instancedMesh ref={ref} args={[undefined, undefined, 1000]}>
4. Model Loading
const { scene } = useGLTF('/model.glb');
return <primitive object={scene} />;
5. Orbit Controls
<OrbitControls enableDamping />
6. Pointer Event
<mesh onClick={() => setSelected(true)}>
7. Preload
useGLTF.preload('/model.glb');
8. LOD
<Detailed distances={[0, 10, 20]}>...</Detailed>
9. Environment
<Environment preset="city" />
10. Html Overlay
<Html position={[0, 1, 0]}><div>Label</div></Html>
Visual
The Scene Graph
┌─────────────────────────────────────────────────────────┐
│ <Canvas> │
│ → creates THREE.Scene, Camera, Renderer │
│ │ │
│ ▼ │
│ <mesh> │
│ → creates THREE.Mesh │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ <boxGeometry> <meshStandardMaterial> │
│ → THREE.BoxGeometry → THREE.MeshStandardMaterial │
│ │ │ │
│ └────┬────┘ │
│ ▼ │
│ mesh.geometry = geometry │
│ mesh.material = material │
│ scene.add(mesh) │
└─────────────────────────────────────────────────────────┘
Ref vs State
┌─────────────────────────────────────────────────────────┐
│ REF (mutation, no re-render) │
│ │
│ useFrame((state, delta) => { │
│ ref.current.rotation.x += delta; │
│ }); │
│ │
│ The mesh rotates every frame. │
│ React does not re-render. │
│ The GPU receives the new matrix. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ STATE (structure, re-render) │
│ │
│ const [selected, setSelected] = useState(false); │
│ <mesh scale={selected ? 1.5 : 1} /> │
│ │
│ Clicking changes the scale once. │
│ React re-renders the component. │
│ The prop diff updates the mesh. │
└─────────────────────────────────────────────────────────┘
The Draw Call Difference
┌─────────────────────────────────────────────────────────┐
│ WITHOUT INSTANCING │
│ │
│ 1000 <mesh> components │
│ → 1000 draw calls │
│ → 1000 geometry uploads │
│ → 1000 material uploads │
│ │
│ GPU overhead dominates. │
│ Frame rate drops. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ WITH INSTANCING │
│ │
│ 1 <instancedMesh> with 1000 instances │
│ → 1 draw call │
│ → 1 geometry upload │
│ → 1 material upload │
│ │
│ The per-instance matrix buffer handles the variation. │
└─────────────────────────────────────────────────────────┘
The WebGPU Transition
┌─────────────────────────────────────────────────────────┐
│ v9 (React 19) │
│ WebGLRenderer by default │
│ WebGPU via async gl prop │
│ │
│ v10 (alpha) │
│ WebGPU support first-class │
│ <Canvas renderer> enables WebGPURenderer │
│ TSL hooks in @react-three/tsl │
│ Renderer chosen by Canvas, loaded on demand │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Package | @react-three/fiber |
| Core idea | React renderer for three.js |
| Canvas | Creates renderer, scene, camera |
| Scene graph | Component tree is the scene |
| Per-frame animation | useFrame + ref mutation |
| Structure changes | React state |
| Instancing | instancedMesh for repeated objects |
| Model loading | useGLTF + Suspense |
| Ecosystem | @react-three/drei, postprocessing, rapier |
| WebGPU | v10 with <Canvas renderer> |
Key takeaways:
- R3F is a renderer, not a wrapper. It replaces the DOM as the rendering target. The component tree describes the scene graph, and the reconciler creates the underlying three.js objects .
- The ref vs state split is the performance rule. Per-frame animation mutates refs inside
useFrame. Structure changes—which objects exist, what is selected—use state. Animating through state is the most common cause of slow R3F scenes . - Delta multiplication is required. A fixed increment makes the animation speed depend on the frame rate. Multiply by
deltafor consistent motion across displays . - Instancing is not optional at scale. Each
<mesh>is a draw call. An<instancedMesh>renders many copies in one draw call, and the per-instance matrices are updated in the frame loop . - The declarative model forbids object creation in the render body. Geometry and material belong in child elements, not as props with
newcalls. The object is created once and attached . - The ecosystem fills the production needs.
@react-three/dreiprovides loaders, controls, environment maps, text, and HTML overlays. The postprocessing, physics, and XR libraries build on the same model . - Suspense is the loading pattern.
useGLTFthrows a promise while the model loads, and a<Suspense>boundary renders the fallback.useGLTF.preloadstarts the load before the component mounts . - WebGPU is the current transition. v10 brings first-class WebGPU support and TSL hooks, with the renderer chosen by the Canvas and loaded on demand. v9 remains the stable WebGL path for React 19 applications .
Remember: React Three Fiber is the same React you already know, rendering to a different target. The component tree is the scene graph, the props are the three.js properties, and the hooks are the bridge to the frame loop. The performance discipline is the discipline of any real-time system: mutate the objects that change every frame, do not re-render what does not need to re-render, and batch the draw calls that share geometry. The ecosystem provides the rest, and the WebGPU transition is the next chapter in the renderer’s evolution.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!