Declarative 3D in React Applications
React Three Fiber (R3F) wraps Three.js in React's component model. You describe scenes declaratively in JSX. Props control position, rotation, scale, material, and geometry. State changes trigger targeted updates to Three.js objects, and hooks provide side effects, animation, and resource loading.
For React applications, React Three Fiber is usually the right choice. It aligns with how the rest of the codebase works. Components compose, state flows downward, and the same developers who build your forms and dashboards can work on 3D components using familiar patterns. R3F works at scale, in production. The harder problems are knowing when to reach for it instead of vanilla Three.js, how to avoid the performance traps that come with declarative 3D, and how to keep a complex scene maintainable as it grows. This page covers all three.
React Three Fiber Scene Architecture
The diagram below is the shape of most real React Three Fiber scenes: a Canvas root with layers for setup, data, interaction, and UI. Click any component to see its props, hooks, and re-render behaviour, then use the simulation buttons to watch which components re-render for different state changes.
The Constraint: Declarative vs Imperative
Three.js is imperative. You create objects, add them to scenes, and mutate properties directly. mesh.position.x = 5 happens immediately. React is declarative. You describe the desired state and React reconciles.
The tension: React's re-render model can conflict with Three.js's performance requirements. A React state update that re-renders a parent component can cascade to re-render every 3D object in the scene, even those unaffected by the change. In a scene with thousands of objects, this kills frame rates. R3F's custom reconciler mitigates this, but only if you structure your application correctly.
R3F bridges the two models by translating JSX declarations into Three.js operations. A <mesh position={[5, 0, 0]}> element creates a Mesh and sets its position. When the position prop changes, R3F updates the existing Mesh rather than creating a new one. The architecture challenge is keeping React's update cycle from interfering with frame-rate-critical rendering.
The Naive Approach
Tutorial-grade R3F code works with simple scenes but creates problems at scale. These patterns persist because they are the easiest to write. The first two are about re-render blast radius.
The next pair waste GPU resources by recreating objects React thinks are new on every render.
<meshStandardMaterial color="red" /> creates a new material on every render. Three.js silently disposes the old one and uploads the new one to the GPU.And one trap is simply reinventing what the library already gives you.
This produces an application that works with a single spinning cube but struggles the moment the scene has real data or real complexity.
State Management with Zustand
R3F recommends Zustand for 3D state management. Zustand stores live outside React's render cycle. Components subscribe to specific slices of state using selectors. Only components that consume changed state re-render.
Scene-Level State
Camera position, selected object, filter settings. Lives in a Zustand store. Changes trigger re-renders only in components that subscribe to the changed slice.
Per-Frame State
Animation progress, interpolated positions, camera smoothing. Lives in refs, never in React state. Refs do not trigger re-renders. Updated inside useFrame at 60fps.
Application State
User data, API responses, form inputs. Lives in React Query, Redux, or a separate Zustand store. The 3D scene reads this data but does not own it.
The boundary is clear: React state for things that change infrequently (user clicks a filter, loads a dataset). Refs and direct Three.js mutation for things that change every frame (animation, camera smoothing, physics). Mixing these up is the most common source of R3F performance problems.
Component Architecture
Structure the scene as a component tree that mirrors the logical structure of your 3D content. Each layer has clear responsibilities, and state changes in one layer do not cascade to others.
Data changes affect only the DataLayer. Interaction state changes affect only the InteractionLayer. UI updates affect only the UILayer. This isolation is the key to maintaining performance as the scene grows. Without it, a tooltip update re-renders the entire point cloud.
A Worked Example
The patterns above (a Zustand store, layered components, ref-based animation) come together in a typical scene. Most React Three Fiber examples online stop at a spinning cube, so here is something closer to production: interactive data points with smooth selection animation, Zustand for state, useFrame for per-frame interpolation, and Drei for controls and environment.
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import { useRef, useMemo } from 'react';
import { create } from 'zustand';
import * as THREE from 'three';
// Zustand store: state outside React's render cycle
const useStore = create((set) => ({
selected: null,
setSelected: (id) => set({ selected: id }),
}));
const targetScale = new THREE.Vector3();
function DataPoint({ position, id, colour }) {
const ref = useRef();
const selected = useStore((s) => s.selected === id);
// Shared geometry via useMemo (created once, reused)
const geometry = useMemo(() => new THREE.SphereGeometry(0.3, 16, 16), []);
// useFrame: animation at 60fps, no re-renders
useFrame((_, delta) => {
const s = selected ? 1.5 : 1;
targetScale.set(s, s, s);
ref.current.scale.lerp(targetScale, delta * 5);
});
return (
<mesh
ref={ref}
position={position}
geometry={geometry}
onClick={() => useStore.getState().setSelected(id)}
>
<meshStandardMaterial color={selected ? '#4b93d6' : colour} />
</mesh>
);
}
function Scene({ data }) {
return (
<Canvas camera={{ position: [0, 5, 10], fov: 50 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[10, 10, 5]} />
<Environment preset="studio" />
<OrbitControls enableDamping />
{data.map((point) => (
<DataPoint key={point.id} {...point} />
))}
</Canvas>
);
}
Each DataPoint subscribes only to its own selection status through the selector (s) => s.selected === id. Click one point and only that point and the previously selected one re-render. The scale animation lives in useFrame with a ref, so it never touches React. Geometry is memoised, so every point shares one GPU buffer. The same skeleton scales to thousands of interactive objects without dropping frames.
Memoisation and Performance
R3F's reconciler is efficient, but React's rendering model means unnecessary re-renders can cascade. Four patterns prevent the most common performance problems.
useMemo for geometry and material
Create expensive objects once and reuse them. useMemo(() => new BoxGeometry(1,1,1), []) creates the geometry once. Without this, every parent re-render creates and disposes GPU resources.
React.memo on sub-components
Prevent re-renders when parent state changes but the child's props have not. Wrapping data-heavy components in React.memo is essential for scenes with many objects.
useFrame for animation
Access Three.js objects via refs inside useFrame. Mutate them directly. ref.current.rotation.y += 0.01 runs at 60fps without React involvement. Never set React state inside useFrame.
Selective Zustand subscriptions
Subscribe to the exact state slice a component needs: useStore(s => s.count). The component re-renders only when count changes, not when any store value changes.
The useFrame Hook
useFrame runs once per frame, outside React's render cycle. It receives the Three.js state (clock, camera, scene, renderer) and the frame delta. Getting the boundary right between useFrame and React state is where most performance problems are won or lost, so it's worth being deliberate about what goes where.
| Use useFrame for | Do not use useFrame for |
|---|---|
| Animation and interpolation | State updates that trigger re-renders (60 re-renders/sec) |
| Camera smoothing and following | Data fetching (60 API calls/sec) |
| Physics simulation steps | Heavy computation (eats 30%+ of frame budget) |
| Ref-based position/rotation updates | DOM manipulation or HTML updates |
The rule is simple: if the update needs to happen every frame, use useFrame with refs. If the update happens in response to user action, use React state. The two should rarely cross.
The Drei Ecosystem
Drei is a collection of React Three Fiber components and hooks that eliminate boilerplate for common patterns. Most production R3F work leans on Drei from day one, so it is worth knowing what ships in the box before you build a helper of your own.
Each of these eliminates boilerplate that is tedious to build from scratch. OrbitControls alone handles mouse, touch, keyboard, and pointer lock. useGLTF handles loading, caching, error handling, and preloading.
When to Use Vanilla Three.js
R3F adds a layer of abstraction. This layer has a cost. There are specific situations where vanilla Three.js is the better choice.
The next two are less about raw performance and more about proportion. A wrapper earns its keep on a real application, not on a one-off embed.
Outside those cases, the default points the other way.
The pragmatic default: For most applications where Three.js is part of a larger React application (dashboards, configurators, data exploration tools), R3F reduces code volume, improves maintainability, and integrates naturally with the existing architecture.
The choice usually comes down to a few axes: scene definition, state, cleanup, and how much of the render loop you want to own. The table below lines them up side by side, which is the quickest way to settle React Three Fiber vs Three.js for a given project.
| Aspect | R3F | Vanilla Three.js |
|---|---|---|
| Scene definition | Declarative JSX | Imperative API calls |
| State management | Zustand + React hooks | Manual store or custom events |
| Cleanup | Automatic on unmount | Manual dispose() calls |
| Learning curve | React + Three.js concepts | Three.js only |
| Ecosystem | Drei, Postprocessing, Zustand | Lower-level libraries |
| Best for | React apps with 3D features | Standalone 3D, max performance |
Integration Patterns
R3F applications are React applications. Data flows into the scene via props and Zustand stores, and interactions flow back out via callbacks. The scene is a view, not a data source: it renders what the application hands it and reports what the user did.
Routing is the part most teams get wrong. Mount and unmount Canvas components with React Router and R3F handles disposal automatically, or keep the Canvas mounted and swap the content inside it for a shared background. For testing, React Testing Library with a WebGL mock covers interaction callbacks, and Playwright screenshot comparison covers visual regression. Once the architecture is sound, the next concern is usually frame budget, which we cover in Three.js performance and optimisation, and concrete builds like product configurator architecture show these patterns end to end.
The Business Link
React Three Fiber reduces the cost of building and maintaining 3D features in React applications. The pool of React developers is large; the pool of Three.js specialists is small. R3F lets you hire for React skills and add 3D capability, rather than the reverse. You're not staffing a separate discipline to ship one feature.
The practical difference: With R3F, your React team reviews 3D code in JSX, state management is consistent across 2D and 3D, and component reuse is natural. The alternative (vanilla Three.js inside a React application) creates a hard boundary between two styles of code, where integration bugs are subtle, persistent, and only fixable by the one developer who understands both sides.
Build 3D Features in React
We build React applications with integrated 3D features using React Three Fiber. Product configurators, data visualisations, spatial interfaces: built with the same component patterns and state management as the rest of your application.
Let's talk about your project →