Official-docs layer on top of react-three-fiber-fundamentals.md. That doc owns the mental model (JSX to Three objects, attach, args, useFrame basics, drei, Leva). This one owns the full Canvas prop surface, the loop and state APIs, events, performance scaling, portals, testing, and TypeScript.
Canvas: full prop surface
| Prop | Default | What it does |
|---|---|---|
children | Three JSX elements or components | |
fallback | DOM JSX shown when WebGL is not supported | |
gl | {} | Renderer options, or a sync/async callback returning your own renderer |
camera | { fov: 75, near: 0.1, far: 1000, position: [0, 0, 5] } | Camera props, or your own THREE.Camera instance |
scene | {} | Scene props, or your own THREE.Scene instance |
shadows | false | true (PCFSoft) or 'basic' | 'percentage' | 'soft' | 'variance', feeds gl.shadowMap |
raycaster | {} | Props for the default raycaster |
frameloop | 'always' | 'always' | 'demand' | 'never' |
resize | { scroll: true, debounce: { scroll: 50, resize: 0 } } | react-use-measure options |
orthographic | false | Swap in an OrthographicCamera |
dpr | [1, 2] | Pixel ratio, fixed number or [min, max] clamp |
legacy | false | Disables THREE.ColorManagement (r139+) |
linear | false | Switch off sRGB color space and gamma correction |
flat | false | NoToneMapping instead of ACESFilmicToneMapping |
events | built-in manager | Event system config as a function of state |
eventSource | gl.domElement.parentNode | DOM element (or ref) events subscribe to |
eventPrefix | 'offset' | Which event coords feed pointer x/y ('client', 'page', 'layer', 'screen') |
onCreated | (state) => {} after the canvas rendered, before commit | |
onPointerMissed | Fires for clicks that hit no object |
- Default renderer:
antialias: true,alpha: true,powerPreference: "high-performance",SRGBColorSpaceoutput, ACES tone mapping cameraandsceneaccept a real instance, so you can own either object and let R3F render into it- The async
glcallback enables WebGPU:
<Canvas
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props)
await renderer.init()
return renderer
}}
/>
eventSource+eventPrefixmatter when the canvas is transformed, scrolled, or events should come from a wrapping element instead of the canvas itself. You can also rewire at runtime withstate.events.connect(domNode)
Frameloop modes and invalidate
frameloop="always"renders every frame (default)."demand"renders only when something invalidates."never"renders only when you calladvance()yourself- In demand mode, React prop changes invalidate automatically. Mutations React cannot see (camera controls, imperative tweaks) need a manual
invalidate() invalidate()schedules a frame, it does not render immediately. Multiple calls in one tick coalesce into one frame
import { invalidate, advance } from '@react-three/fiber'
<Canvas frameloop="demand" />
// inside components
const invalidate = useThree((state) => state.invalidate)
controls.addEventListener('change', invalidate)
// frameloop="never": drive the loop yourself
advance(performance.now())
v9 and React 19
- v9 pairs with React 19; it is primarily a compatibility, performance, and types release
StrictModenow inherits from the parent renderer. You no longer redeclare it inside<Canvas>, and previously hidden double-invoke side effects may surface- Hardcoded prop types are gone:
MeshPropsbecomesThreeElements['mesh'];Node,Object3DNode, and friends consolidate intoThreeElement; CanvasPropsis renamedCanvasProps - Automatic sRGB texture conversion was removed. Annotate color textures manually:
texture.colorSpace = THREE.SRGBColorSpace useLoaderaccepts a loader instance (not just a class) for controlled poolingactnow comes from React itself and works synchronously with all renderers
useFrame: priority and taking over the render loop
useFrame((state, delta, xrFrame) => { ... }, renderPriority)
- Callbacks run in ascending priority order, lowest first, highest last
- Any positive
renderPrioritydisables R3F's automatic render. You now own the draw and must callgl.render(scene, camera)yourself. This is how multi-pass, HUD, and split-view render pipelines work
function Render() {
// takes over the render loop
useFrame(({ gl, scene, camera }) => {
gl.render(scene, camera)
}, 1)
}
- Negative priorities keep automatic rendering and only order execution across components (run before everything at
-1, etc.) - Never
setStateinsideuseFrame. Mutate refs and Three objects directly - Global loop hooks exist as top-level exports:
addEffect(each frame),addAfterEffect(after render),addTail(when rendering stops),flushGlobalEffects(when manually driving a loop)
useThree: selectors and subscription semantics
- Bare
useThree()returns the whole state and re-renders on any state change. Pass a selector to subscribe to one slice:
const camera = useThree((state) => state.camera)
- Selectors re-render only when the selected reference changes. There is no reactivity from Three internals: mutating
camera.zoomdoes not notify subscribers, only replacing the object does - State fields:
gl,scene,camera,raycaster,pointer,clock,size,viewport,frameloop,performance,events, plusset(),get(),invalidate,advance,setEvents get()reads fresh state without subscribing, for transient per-frame or event-handler reads that should not re-render:
const get = useThree((state) => state.get)
useFrame(() => {
get().camera // always current, no subscription
})
set()merges into the root state, which is how camera or event-source overrides are installed imperatively- Hooks only work inside the
<Canvas>tree, because they rely on its context
Events system
Handlers on any Object3D: onClick, onContextMenu, onDoubleClick, onPointerUp, onPointerDown, onPointerOver, onPointerOut, onPointerEnter, onPointerLeave, onPointerMove, onWheel, plus onPointerMissed (also valid on Canvas) and onUpdate (fresh props applied).
Event objects merge the DOM event with the raycast intersection:
({
...DomEvent,
...Intersection, // object, point, distance, ...
intersections, // first intersection per intersected object
object, // the object actually hit
eventObject, // the object carrying the handler
unprojectedPoint, ray, camera, sourceEvent,
delta, // pixel drag distance between down and up
})
Propagation and capture
- Events go to the nearest intersection first, then bubble up its ancestors. Because objects occlude each other in 3D,
stopPropagation()also blocks delivery to objects further along the ray, and firespointerouton the ones losing the pointer - Pointer capture works through
event.target:
<mesh
onPointerDown={(e) => {
e.stopPropagation()
e.target.setPointerCapture(e.pointerId)
}}
onPointerUp={(e) => e.target.releasePointerCapture(e.pointerId)}
/>
raycast prop and filtering
- Every object accepts a
raycastprop to override how it is hit-tested.raycast={() => null}opts the object out of pointer events entirely; drei'smeshBoundsswaps in a cheap bounding-sphere test for complex meshes - Global control lives on the Canvas
eventsprop:enabledtoggles the system,priorityorders it,filterreorders or prunes the intersections array,computemaps pointer coords onto the raycaster (this is how portals and tracked views define their own event space) state.events.update()forces a raycast without pointer movement (useful when objects move under a stationary cursor)
Performance scaling
Movement regression
state.performanceis{ current, min, max, debounce, regress }. Callingregress()(e.g. on control change) dropscurrenttowardminfordebouncems, then it returns tomax- Nothing happens by itself: components opt in by reading
performance.currentand shedding quality, the canonical example being dpr scaling:
function AdaptivePixelRatio() {
const current = useThree((state) => state.performance.current)
const setPixelRatio = useThree((state) => state.setDpr)
useEffect(() => setPixelRatio(window.devicePixelRatio * current), [current])
return null
}
drei packages this as <AdaptiveDpr pixelated /> and <AdaptiveEvents />.
PerformanceMonitor
- drei's
<PerformanceMonitor>samples the real FPS and firesonIncline/onDeclinewhen it moves outside bounds, so you can raise or lower quality (dpr, effects, shadow resolution) adaptively instead of guessing device tiers
Instances and reuse
- Reuse geometries and materials by defining them once (module scope or
useMemo) and referencing them from many meshes; each unique geometry and material costs GPU time. When constructing materials outside<Canvas>, setTHREE.ColorManagement.enabled = true useLoadercaches by URL, so the same asset loaded twice is one resource- Hundreds of identical objects belong in one
<instancedMesh>(one draw call). Distance-based quality belongs to drei<Detailed distances={[0, 10, 20]}> - React 18+
startTransitionspreads expensive updates (geometry rebuilds) across frames
Object reuse and dispose
- Never pass
new THREE.Something()in props on every render; use declarative children withargs, or memoize the instance <primitive object={existing} />mounts a pre-built object. Never mount the same object twice; clone it if you need copies- R3F auto-disposes objects on unmount.
dispose={null}disables that for the whole subtree, which is required for globally cached assets (GLTF caches, shared materials):
<group dispose={null}>
<mesh geometry={globalGeometry} material={globalMaterial} />
</group>
- Loaded GLTF scenes are cached, so components rendering their meshes should carry
dispose={null}or a second mount will reference disposed resources
Portals: createPortal
createPortal(children, target, state?)re-parents children into another container: a differentTHREE.Scene, a mesh, or any Object3D, while staying in the same React tree- The third argument injects overrides into the portal's own state layer, most importantly
events.compute, giving the portal its own pointer space:
import { createPortal, useFrame, useThree } from '@react-three/fiber'
function ViewPortal({ children }) {
const [scene] = useState(() => new THREE.Scene())
useFrame((state) => {
state.gl.render(scene, state.camera) // draw the portal scene yourself
}, 1)
return createPortal(children, scene, {
events: { compute: (event, state) => { /* map pointer into this view */ } },
})
}
- This pattern (portal scene + priority
useFrame+ scissor/viewport) is how heads-up displays, render targets, and multi-view single-canvas layouts are built; drei'sHudandViewwrap it
Custom roots and renderers
<Canvas>is a convenience wrapper.createRoot(canvasElement)gives the underlying API: you own the canvas, sizing, and configuration
import { createRoot, events } from '@react-three/fiber'
const root = createRoot(document.querySelector('canvas'))
await root.configure({ events, camera: { position: [0, 0, 50] } })
root.render(<App />)
// root.unmount() to dispose
- The automatic element catalog pulls in the whole
THREEnamespace. For minimal bundles, use a root with a manuallyextended catalog so tree-shaking works; an official Babel plugin automates the transform - Other top-level exports worth knowing:
applyProps(object, props)applies R3F-style props to any instance,buildGraph(object3d)collects{ nodes, materials },flushSyncforces synchronous React updates,useInstanceHandleexposes the internalinstance.__r3fstate
Testing: @react-three/test-renderer
- Renders the R3F tree headlessly (no GPU) and exposes the scene graph for assertions:
import ReactThreeTestRenderer from '@react-three/test-renderer'
const renderer = await ReactThreeTestRenderer.create(
<mesh>
<boxGeometry args={[2, 2]} />
<meshStandardMaterial color={0x0000ff} />
</mesh>,
)
renderer.toGraph() // serializable scene graph
renderer.scene.children // TestInstances wrapping Three objects
await renderer.fireEvent(mesh, 'click', mockEventData)
await renderer.advanceFrames(2, 0.016) // run useFrame subscribers
await ReactThreeTestRenderer.act(async () => { ... })
renderer.update(<Next />)
renderer.unmount()
advanceFrames(frames, delta)is the lever for testinguseFramelogic deterministicallyfireEventtargets a TestInstance and synthesizes the R3F event, no real raycasting or DOM needed
TypeScript
- Type props with the
ThreeElementsinterface, never hand-written prop types:
import { ThreeElements } from '@react-three/fiber'
type FooProps = ThreeElements['mesh'] & { bar: boolean }
function Foo({ bar, ...props }: FooProps) {
return <mesh {...props} />
}
- Refs are typed with the Three class itself:
useRef<THREE.Mesh>(null!) - Custom classes registered via
extendget JSX types by augmentingThreeElementswithThreeElement:
import { extend, ThreeElement } from '@react-three/fiber'
class CustomElement extends GridHelper {}
extend({ CustomElement })
declare module '@react-three/fiber' {
interface ThreeElements {
customElement: ThreeElement<typeof CustomElement>
}
}
- The v9 factory signature skips the augmentation entirely and avoids namespace bleed:
const Element = extend(CustomElement)returns a typed component you render as<Element /> - The old
declare global { namespace JSX ... }pattern andNode/Object3DNodehelper types are v8-era; do not use them in v9
Common mistakes
- Expecting
invalidate()to render synchronously; it only schedules a frame - Passing a positive
useFramepriority and forgetting you now must callgl.renderyourself (black canvas) - Selecting the whole state with
useThree()in hot components, causing re-renders on every state change; use selectors orget() - Expecting
useThreeselectors to react to mutated Three internals likecamera.zoom; only reference swaps notify - Setting React state inside
useFrame - Forgetting
dispose={null}on cached GLTF content, so unmounting one instance disposes the shared geometry - Mounting the same object via
<primitive>twice instead of cloning - Relying on removed v8 types (
MeshProps,Node, global JSX namespace) after upgrading to v9 - Assuming color textures are sRGB-converted automatically in v9; set
texture.colorSpaceyourself - Using
stopPropagation()without realizing it also blocks occluded objects behind the target and fires theirpointerout