../

React Three Fiber

Three.js as React components with @react-three/fiber v9 (React 19) and the @react-three/drei v10 helper library: the Canvas, how JSX maps to three classes, typed refs, useFrame / useThree / useLoader with Suspense, pointer events, the drei toolbox, gltfjsx, performance and TypeScript. Assumes React and React hooks; the three.js concepts underneath are in Fundamentals and Models & animation.

Setup & versions

npm i three @react-three/fiber @react-three/drei
npm i -D @types/three
PackageCurrent linePairs with
@react-three/fiberv9 (9.8.x)React 19 (peer range >=19 <19.4 in 9.8); three >=0.156
@react-three/dreiv10fiber 9, React 19
fiber v8 / drei v9legacyReact 18
fiber v10alpha (2026)not for production yet
@react-three/postprocessingeffects via pmndrs postprocessingfiber 9
@react-three/rapierphysicsfiber 9
levadebug GUIany

R3F is a React renderer (like react-dom) that creates and mutates three objects; there is no wrapper per class. Anything three can do, R3F can do, and everything new in three is available at once.

 react-dom tree                 R3F tree (inside <Canvas>)
 ─────────────                  ──────────────────────────
 <App>                          <mesh>          → new Mesh()
   <Canvas> ── owns ──►           <boxGeometry> → attach "geometry"
     (canvas, renderer,           <meshStandardMaterial>
      scene, camera,                            → attach "material"
      raycaster, loop)          useFrame(cb)    → runs every frame

Canvas

import { Canvas } from "@react-three/fiber";
 
export function App() {
  return (
    <Canvas
      camera={{ position: [3, 2, 5], fov: 50 }}
      dpr={[1, 2]}            // clamp devicePixelRatio
      shadows="percentage"    // see note below
      gl={{ antialias: true }}
      frameloop="always"      // | "demand" | "never"
      onPointerMissed={() => select(null)}
    >
      <Scene />
    </Canvas>
  );
}
PropDefaultNotes
cameraPerspectiveCamera(75, aspect, 0.1, 1000) at z = 5props object or a camera instance; manual: true to own the projection
orthographicfalsedefault camera becomes orthographic
dpr[1, 2]number or [min, max] clamp
glWebGLRendererparams object, instance, or (defaultProps) => renderer (may be async)
shadowsfalsetrue = PCFSoftShadowMap; or "basic" | "percentage" | "soft" | "variance"
frameloop"always""demand" renders only after changes / invalidate()
flatfalseNoToneMapping instead of R3F's default ACESFilmicToneMapping
linearfalsedisable sRGB output (and texture auto-tagging)
scene, raycasterprops for the defaults, or an instance for scene
events, eventSource, eventPrefixpointer events on the canvasshare events with a DOM parent (overlays, View)
onCreated(state) => … once the root exists
onPointerMissedclick that hit nothing
performance{ min, max, debounce } for regression (see Performance)
style, classNamefills its parentgive the parent a size
  • The canvas fills its parent; a parent with no height gives a 0-pixel canvas (the classic blank page).
  • R3F defaults differ from vanilla three: ACES tone mapping, sRGB output, antialiasing on, dpr clamp.
  • shadows or "soft" requests PCFSoftShadowMap, which three r182+ replaces with PCFShadowMap plus a console warning; shadows="percentage" asks for PCF directly.

JSX → three mapping

Every three export is available as a lower-camel-case element: Mesh → <mesh>, MeshStandardMaterial → <meshStandardMaterial>, PointLight → <pointLight>. Line, Audio, Source and Path clash with DOM/SVG names and are exposed as <threeLine>, <threeAudio>, <threeSource>, <threePath>.

JSXEquivalent three code
<boxGeometry args={[1, 2, 1]} />new BoxGeometry(1, 2, 1); changing args rebuilds the object
position={[1, 0, 0]}obj.position.set(1, 0, 0) (props on math types call .set)
scale={2}obj.scale.setScalar(2)
color="hotpink" / color={0xff69b4}material.color.set(...)
position-x={1}dash-case pierces: obj.position.x = 1
material-roughness={0.3}mesh.material.roughness = 0.3
attach="material"parent.material = child (auto for geometries and materials)
attach="material-0"parent.material[0] = child (arrays)
attach={(parent, self) => cleanup}custom attach function returning a detach
<primitive object={gltf.scene} />insert an existing object as-is (not disposed on unmount)
dispose={null}skip automatic dispose() of this subtree on unmount
onUpdate={(self) => …}called after props are applied
<mesh position={[0, 1, 0]} rotation-y={Math.PI / 4}
  castShadow>
  <sphereGeometry args={[0.5, 64, 32]} />
  <meshPhysicalMaterial
    color="#88ccff" roughness={0.1} transmission={1}
    thickness={0.5}
  />
</mesh>
<directionalLight position={[5, 8, 3]} intensity={2}
  castShadow shadow-mapSize={[2048, 2048]}
  shadow-camera-far={40} />

Color maps passed as JSX props on built-in materials (map, emissiveMap, sheenColorMap, specularColorMap, envMap) are tagged SRGBColorSpace automatically. Textures assigned in code or used by custom shaders need colorSpace set by hand.

R3F disposes geometries, materials and textures it created when they unmount. Objects you pass via <primitive> or build yourself are yours to dispose.

Refs & TypeScript types

import { useRef } from "react";
import type { Mesh, MeshStandardMaterial } from "three";
import { type ThreeElements } from "@react-three/fiber";
 
// component props = the <mesh> element props + yours
type SpinnerProps = ThreeElements["mesh"] & {
  speed?: number;
};
 
export function Spinner(
  { speed = 1, ...props }: SpinnerProps,
) {
  const ref = useRef<Mesh>(null!); // non-null after mount
  const mat = useRef<MeshStandardMaterial>(null!);
  useFrame((_, dt) => {
    ref.current.rotation.y += dt * speed;
  });
  return (
    <mesh ref={ref} {...props}>
      <boxGeometry />
      <meshStandardMaterial ref={mat} color="orange" />
    </mesh>
  );
}
TypeUse
ThreeElements["mesh"]props of <mesh> (v9 replaced MeshProps etc.)
ThreeElement<typeof Cls>props for a custom/extended class
ThreeEvent<PointerEvent>pointer handler argument
RootStatewhat useThree returns
CanvasProps<Canvas> props (was Props in v8)
Vector3, Euler, Color (from fiber)the JSX shorthand types ([x, y, z] | Vector3 | number)
useRef<Mesh>(null!)ref that is non-null in useFrame and effects; React 19 requires the initial argument

useFrame

useFrame((state, delta, xrFrame) => {
  ref.current.rotation.x += delta;          // seconds
  ref.current.position.y =
    Math.sin(state.clock.elapsedTime) * 0.3;
});
 
// positive priority: you take over rendering
useFrame(({ gl, scene, camera }) => {
  gl.render(scene, camera);
}, 1);
RuleWhy
Mutate refs, never setState per frameReact re-renders cost milliseconds; the loop runs 60–144×/s
Scale by deltaframe-rate independence
Only inside <Canvas> childrenhooks read the canvas store from context
Any priority > 0disables R3F's automatic render; highest priority runs last
Allocate outside the callbackconst v = useMemo(() => new Vector3(), []), reuse
state.clock is a THREE.Clockwith three r183+ this logs a one-time Clock deprecation warning; delta is unaffected

useThree

const camera = useThree((s) => s.camera);  // selector:
const size = useThree((s) => s.size);      // re-render only
const invalidate = useThree((s) => s.invalidate); // when
                                           // that changes
RootState fieldContents
glthe renderer
scene, camera, raycasterdefaults (replaceable with set)
sizecanvas size in CSS px
viewportsize in world units at the camera's target distance; dpr, aspect
pointernormalized device coordinates of the pointer (Vector2)
clockelapsed time
invalidate(frames?)request a frame (frameloop="demand")
setDpr, setFrameloop, setSizeruntime changes
performancecurrent, regress() for adaptive quality
eventsthe event manager (connect, enabled)
get, setread/write the store imperatively

Avoid useThree() without a selector: it re-renders the component on every store change (e.g. resize).

Loading & Suspense

import { Suspense } from "react";
import { useLoader } from "@react-three/fiber";
import { TextureLoader } from "three";
 
function Crate() {
  const map = useLoader(TextureLoader, "/crate.jpg");
  return (
    <mesh>
      <boxGeometry />
      <meshStandardMaterial map={map} />
    </mesh>
  );
}
 
<Suspense fallback={<Spinner />}>
  <Crate />
</Suspense>
APINotes
useLoader(Loader, url | url[], ext?, onProgress?)suspends until loaded; cached by URL; v9 also accepts a loader instance
useLoader.preload(Loader, url)fetch early, before the component mounts
useLoader.clear(Loader, url)evict from the cache
useGLTF(url, draco?, meshopt?, extend?) (drei)GLTFLoader with Draco (gstatic CDN decoder by default) and Meshopt; useGLTF.preload(url)
useTexture(url | url[] | Record) (drei)textures; the record form returns the same keys ({ map, normalMap })
useProgress() (drei){ active, progress, loaded, total, item } for a loading UI
<Loader /> (drei)ready-made HTML progress overlay
<Preload all /> (drei)compile and upload everything in the scene up front

Cached results are shared: two components using the same useGLTF URL get the same scene graph, and an Object3D can have only one parent. Clone for multiple copies (scene.clone(), or drei's <Clone>, or SkeletonUtils.clone for skinned models).

Events

import type { ThreeEvent } from "@react-three/fiber";
 
<mesh
  onClick={(e: ThreeEvent<MouseEvent>) => {
    e.stopPropagation();          // nearest hit only
    console.log(e.object.name, e.point, e.distance);
  }}
  onPointerOver={(e) => {
    e.stopPropagation();
    setHover(true);
  }}
  onPointerOut={() => setHover(false)}
/>
Event fieldMeaning
objectthe object actually hit
eventObjectthe object whose handler is running (events bubble up the three tree)
point, distance, face, uv, normalfrom the underlying Raycaster intersection
instanceIdfor InstancedMesh
intersectionsevery hit, nearest first
pointer, ray, cameraNDC, ray and camera used
deltapx moved between pointerdown and up (tell a click from a drag)
nativeEventthe DOM event
stopPropagation()stop bubbling and stop the event reaching objects behind
BehaviorDetail
HandlersonClick, onDoubleClick, onContextMenu, onWheel, onPointerDown/Up/Move/Over/Out/Enter/Leave/Cancel, onPointerMissed
Hit testingonly objects with at least one handler are raycast
Occlusionwithout stopPropagation, objects behind the first hit also receive the event
Pointer capture(e.target as Element).setPointerCapture(e.pointerId) for drags
Raycast costmany handlers on complex meshes: use drei meshBounds (raycast={meshBounds}) or <Bvh>
Cursordrei useCursor(hovered)

drei essentials

HelperDoes
<OrbitControls makeDefault />camera controls; makeDefault registers them in the store for other helpers
<Environment preset="city" />IBL + optional background; presets: apartment, city, dawn, forest, lobby, night, park, studio, sunset, warehouse (fetched from a CDN); or files="/env.hdr"
<Stage>centered model + lighting + shadows in one component
<ContactShadows>, <AccumulativeShadows>soft ground shadows without shadow maps
<Html>DOM inside the scene; transform, occlude, center, distanceFactor
<Text>SDF text (troika); font url, fontSize, anchorX
<Text3D>extruded text from a typeface JSON
<Instances> + <Instance>declarative InstancedMesh with per-instance props and events; limit, range
<Merged>instance several different meshes at once
<Bounds fit clip observe margin={1.2}>frame children; useBounds().refresh(obj).fit() to zoom to a click
<Center>, <Float>center content; bobbing idle motion
useGLTF, useTexture, useAnimationsloading and AnimationMixer wiring (actions[name]?.play())
<Detailed distances={[0, 20, 50]}>LOD
<AdaptiveDpr pixelated />, <PerformanceMonitor>drop quality when FPS falls
<Bvh>BVH-accelerated raycasting for children
<Grid>, <GizmoHelper>, <Stats>, <StatsGl>editor-style helpers
<MeshTransmissionMaterial>, <MeshReflectorMaterial>fancier materials
<View>many viewports sharing one canvas

gltfjsx

gltfjsx turns a GLB into a typed React component with named nodes and materials, so you can edit parts declaratively.

npx gltfjsx public/robot.glb --types --transform
# -t/--types      TypeScript definitions
# -T/--transform  draco, prune, resize textures
#                 → writes *-transformed.glb
# -s/--shadows    castShadow + receiveShadow on meshes
# -i/--instance   instance repeated geometry
# -r/--root       path the .glb is served from
// generated (trimmed) — Robot.tsx
type GLTFResult = GLTF & {
  nodes: { Body: THREE.Mesh; Visor: THREE.Mesh };
  materials: { Metal: THREE.MeshStandardMaterial };
};
 
export function Robot(props: ThreeElements["group"]) {
  const { nodes, materials } = useGLTF(
    "/robot-transformed.glb",
  ) as unknown as GLTFResult;
  return (
    <group {...props} dispose={null}>
      <mesh geometry={nodes.Body.geometry}
        material={materials.Metal} />
      <mesh geometry={nodes.Visor.geometry}>
        <meshStandardMaterial color="cyan" />
      </mesh>
    </group>
  );
}
useGLTF.preload("/robot-transformed.glb");

Generated components reuse the cached geometries and materials, so rendering <Robot /> many times is cheap (unlike <primitive>, which can only be mounted once).

Performance

TechniqueHow
No per-frame Reactmutate refs in useFrame; keep React state for discrete changes
Render on demandframeloop="demand"; call invalidate() after imperative changes (controls call it for you)
Instancing<instancedMesh args={[geo, mat, count]}> or drei <Instances>
Share resourcesdefine geometry/material once (useMemo, or top-level constants) and pass via props
SelectorsuseThree((s) => s.size) instead of useThree()
Transient subscriptionsread zustand stores with getState() / subscribe inside useFrame, not via hooks
Adaptive qualityperformance={{ min: 0.5 }} on Canvas, regress() during interaction, <AdaptiveDpr>
PreloaduseGLTF.preload, <Preload all /> to avoid mid-interaction stalls
Visibility over mounttoggle visible rather than mounting/unmounting heavy subtrees (recompiles, re-uploads)
Avoid key churnchanging key or args rebuilds objects
Measuregl.info.render.calls, drei <Stats> / <StatsGl>, React profiler for re-renders
// demand mode: animate only while something moves
function Door({ open }: { open: boolean }) {
  const ref = useRef<Group>(null!);
  const invalidate = useThree((s) => s.invalidate);
  useFrame((_, dt) => {
    const target = open ? -Math.PI / 2 : 0;
    const r = ref.current.rotation;
    r.y += (target - r.y) * Math.min(1, dt * 8);
    if (Math.abs(target - r.y) > 1e-3) invalidate();
  });
  useEffect(() => invalidate(), [open, invalidate]);
  return <group ref={ref}>{/* door mesh */}</group>;
}

Extending & WebGPU

Classes that are not part of three (addons, your own) must be registered before use in JSX.

import { extend, type ThreeElement }
  from "@react-three/fiber";
import { OrbitControls } from
  "three/addons/controls/OrbitControls.js";
 
// v9 option 1: component factory, no typing needed
const Orbit = extend(OrbitControls);
// <Orbit args={[camera, gl.domElement]} />
 
// option 2: catalog + module augmentation
extend({ OrbitControls });
declare module "@react-three/fiber" {
  interface ThreeElements {
    orbitControls: ThreeElement<typeof OrbitControls>;
  }
}
// <orbitControls args={[camera, gl.domElement]} />

WebGPURenderer in fiber v9: the gl prop may return a promise, and the node classes are registered from three/webgpu.

import * as THREE from "three/webgpu";
import {
  Canvas, extend, type ThreeToJSXElements,
} from "@react-three/fiber";
 
declare module "@react-three/fiber" {
  interface ThreeElements
    extends ThreeToJSXElements<typeof THREE> {}
}
extend(THREE as any);
 
<Canvas gl={async (props) => {
  const r = new THREE.WebGPURenderer(props as any);
  await r.init();
  return r;
}}>
  <mesh>
    <boxGeometry />
    <meshStandardNodeMaterial color="orange" />
  </mesh>
</Canvas>

Not every drei helper works under WebGPURenderer (anything built on GLSL ShaderMaterial does not).

Pitfalls

SymptomCauseFix
Blank pageparent of <Canvas> has no heighthtml, body, #root { height: 100% }
"Hooks can only be used within the Canvas"useFrame/useThree in a DOM componentmove it into a child of <Canvas>
Model appears once onlysame cached scene mounted twice via <primitive>clone, or use a gltfjsx component
Stutter every framesetState in useFrame, or allocations per framerefs, reuse vectors
Object rebuilt on every renderargs={[...]} array built from changing valuesmemoise or only change when needed
Clicks go through objectsmissing stopPropagationcall it in handlers
Colors differ from vanillaACES tone mapping defaultflat on <Canvas> or set gl.toneMapping
Nothing updates in demand modeimperative change without invalidate()call it
Shadows warning in consoleshadows = PCFSoft, removed in three r182shadows="percentage"

Recipes

Typed hover-and-click mesh

Use as the template for interactive objects: typed ref, hover state, cursor, click toggles scale.

import { useRef, useState } from "react";
import { useFrame, type ThreeElements } from
  "@react-three/fiber";
import { useCursor } from "@react-three/drei";
import type { Mesh } from "three";
 
export function Box(props: ThreeElements["mesh"]) {
  const ref = useRef<Mesh>(null!);
  const [hovered, setHovered] = useState(false);
  const [active, setActive] = useState(false);
  useCursor(hovered);
  useFrame((_, dt) => {
    ref.current.rotation.x += dt * 0.5;
  });
  return (
    <mesh {...props} ref={ref}
      scale={active ? 1.5 : 1}
      onClick={(e) => {
        e.stopPropagation();
        setActive((a) => !a);
      }}
      onPointerOver={(e) => {
        e.stopPropagation();
        setHovered(true);
      }}
      onPointerOut={() => setHovered(false)}>
      <boxGeometry />
      <meshStandardMaterial
        color={hovered ? "hotpink" : "orange"} />
    </mesh>
  );
}

Product viewer on demand

Use for a model viewer: IBL, contact shadow, orbit controls, no rendering while idle.

import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import {
  Bounds, ContactShadows, Environment, OrbitControls,
  useGLTF,
} from "@react-three/drei";
 
function Model({ url }: { url: string }) {
  const { scene } = useGLTF(url);
  return <primitive object={scene} />;
}
 
export function Viewer({ url }: { url: string }) {
  return (
    <Canvas frameloop="demand" dpr={[1, 2]}
      camera={{ position: [2, 1.5, 3], fov: 40 }}>
      <Suspense fallback={null}>
        <Bounds fit clip observe margin={1.2}>
          <Model url={url} />
        </Bounds>
        <Environment preset="studio" />
        <ContactShadows position-y={-0.5} opacity={0.5}
          blur={2} far={2} />
      </Suspense>
      <OrbitControls makeDefault enableDamping />
    </Canvas>
  );
}

Instanced grid with hover

Use for thousands of pickable items (data points, tiles) at one draw call.

import { useLayoutEffect, useMemo, useRef } from "react";
import type { ThreeEvent } from "@react-three/fiber";
import { Color, InstancedMesh, Object3D } from "three";
 
const N = 50;
const base = new Color("#4f8cff");
const hot = new Color("#ff5a5a");
 
export function Grid() {
  const ref = useRef<InstancedMesh>(null!);
  const dummy = useMemo(() => new Object3D(), []);
  useLayoutEffect(() => {
    for (let i = 0; i < N * N; i++) {
      dummy.position.set(i % N - N / 2, 0,
        Math.floor(i / N) - N / 2);
      dummy.updateMatrix();
      ref.current.setMatrixAt(i, dummy.matrix);
      ref.current.setColorAt(i, base);
    }
    ref.current.instanceMatrix.needsUpdate = true;
    ref.current.computeBoundingSphere();
  }, [dummy]);
 
  const paint = (e: ThreeEvent<PointerEvent>, c: Color) => {
    e.stopPropagation();
    if (e.instanceId === undefined) return;
    ref.current.setColorAt(e.instanceId, c);
    ref.current.instanceColor!.needsUpdate = true;
  };
  return (
    <instancedMesh ref={ref}
      args={[undefined, undefined, N * N]}
      onPointerMove={(e) => paint(e, hot)}
      onPointerOut={(e) => paint(e, base)}>
      <boxGeometry args={[0.9, 0.2, 0.9]} />
      <meshStandardMaterial />
    </instancedMesh>
  );
}

onPointerOut fires per object, not per instance: for exact "leave instance" behavior, remember the last instanceId from onPointerMove and reset it there.

Animated character with drei

Use to play and cross-fade glTF animation clips from React state.

import { useEffect, useRef } from "react";
import { useAnimations, useGLTF } from "@react-three/drei";
import type { Group } from "three";
 
type Clip = "Idle" | "Walk" | "Run";
 
export function Hero({ clip }: { clip: Clip }) {
  const group = useRef<Group>(null!);
  const { scene, animations } = useGLTF("/hero.glb");
  const { actions } = useAnimations(animations, group);
 
  useEffect(() => {
    const a = actions[clip];
    a?.reset().fadeIn(0.25).play();
    return () => { a?.fadeOut(0.25); };
  }, [clip, actions]);
 
  return <primitive ref={group} object={scene} />;
}
useGLTF.preload("/hero.glb");

Leva-tuned material

Use while developing: live sliders for material and light values without rebuilding.

import { useControls } from "leva";
 
export function Tuned() {
  const { color, roughness, metalness, env } = useControls({
    color: "#ff8844",
    roughness: { value: 0.4, min: 0, max: 1 },
    metalness: { value: 0, min: 0, max: 1 },
    env: { value: 1, min: 0, max: 3 },
  });
  return (
    <mesh>
      <torusKnotGeometry args={[0.6, 0.2, 128, 16]} />
      <meshStandardMaterial color={color}
        roughness={roughness} metalness={metalness}
        envMapIntensity={env} />
    </mesh>
  );
}

References