Three.js From Zero · Article 10
React Three Fiber + Shipping
React Three Fiber + Shipping
You've written nine articles of vanilla Three.js. You understand the engine. Now let's port everything you know to React Three Fiber — the declarative React renderer for Three.js — and ship a real production scene to the web.
This article is the finale. It covers the R3F mental model, maps every vanilla concept you've learned onto its R3F equivalent, walks the drei and postprocessing ecosystems, and ends with the actual deployment recipes (Vercel, Cloudflare, asset CDN, caching) for getting a 3D scene live on a URL.
The demo below is a full R3F scene running with zero build step. React, R3F, and drei load directly from esm.sh via an importmap. Same approach we've used for Three.js all series. Pretty remarkable that you can now write component-based 3D in a single HTML file.
Why R3F at all?
Vanilla Three.js is imperative — you build the scene by calling methods. It's fine for small scenes. It gets painful when scenes grow and parts need to coordinate with the rest of your app (menu toggles, page state, user data).
R3F treats the scene as a React component tree:
function App() {
return (
<Canvas camera={{ position: [2, 2, 3] }}>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="tomato" />
</mesh>
<OrbitControls />
</Canvas>
);
}
Every lowercase tag becomes a Three.js object. <mesh> is
new THREE.Mesh(). <boxGeometry> is new THREE.BoxGeometry().
Attributes become constructor args or property sets. The reconciler creates, updates, and
disposes Three objects as you re-render.
The mental model — three layers
| Layer | Purpose |
|---|---|
<Canvas> | Creates the renderer, scene, default camera. Everything 3D lives inside it. |
Lowercase JSX (<mesh>, <boxGeometry>, etc.) | Three.js constructors. The reconciler manages lifecycle. |
Hooks (useFrame, useThree) | Animation loop + access to renderer/scene/camera/etc. |
That's the whole API. Three hours of tutorials by other people boil down to these three things.
The animation loop — useFrame
In vanilla you wrote renderer.setAnimationLoop((t) => {...}). In R3F you
use a hook:
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function SpinningCube() {
const ref = useRef();
useFrame((state, delta) => {
ref.current.rotation.y += delta;
});
return (
<mesh ref={ref}>
<boxGeometry />
<meshNormalMaterial />
</mesh>
);
}
Key differences from vanilla:
deltacomes as seconds already — not * 0.001nonsense.statehasstate.clock,state.camera,state.scene,state.gl(the renderer). You rarely need it.ref.currentis the real Three.js object. When you need to reach into the escape hatch — raycasting, GPU queries — use the ref.
Refs and useThree
import { useThree } from '@react-three/fiber';
function Debug() {
const { camera, scene, gl, size } = useThree();
// size.width, size.height = canvas dimensions in pixels
// gl = THREE.WebGLRenderer
// camera = the default or nearest parent camera
// scene = the Three.Scene root
return null;
}
Use useThree when you need the renderer directly (screenshots, custom render
passes) or for responsive math that depends on the canvas size.
Drei — the "I want this now" toolbox
@react-three/drei is a library of ~200 prebuilt R3F components for everything you built from scratch in this series:
| Drei helper | Replaces what we did in |
|---|---|
<OrbitControls /> | Article 06 — hand-wiring controls |
<Environment preset="studio" /> | Article 04 — PMREM + HDR loading |
<Gltf src="/model.glb" /> | Article 05 — GLTFLoader + Draco + animations |
<Instances> + <Instance> | Article 09 — InstancedMesh |
<Html /> | 2D DOM elements tracked to 3D positions |
<Text /> | MSDF 3D text |
<ContactShadows /> | Fake contact shadows without shadow maps |
<useGLTF /> | glTF loading hook with caching |
<Stats /> | stats.js panel |
<Sparkles>, <Stars>, <Clouds>, ... | Ready-made effects |
Rule of thumb: if you're about to write vanilla Three.js boilerplate, check drei first. 90% of the time there's a component that does it with better defaults than you'd have written.
Porting the series hero scene to R3F
Here's the Article 07 neon post-processing scene, rewritten as R3F. Forty lines, same look:
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import { EffectComposer, Bloom } from '@react-three/postprocessing';
import { useRef } from 'react';
import * as THREE from 'three';
function Rings() {
const a = useRef(), b = useRef();
useFrame((_, dt) => {
a.current.rotation.z += dt * 0.15;
b.current.rotation.z -= dt * 0.22;
});
return (
<>
<mesh ref={a} rotation-x={Math.PI / 2}>
<torusGeometry args={[2.2, 0.1, 16, 128]} />
<meshStandardMaterial emissive="#38bdf8" emissiveIntensity={2.5} />
</mesh>
<mesh ref={b} rotation-x={Math.PI / 2} rotation-z={Math.PI / 3} scale={0.75}>
<torusGeometry args={[2.2, 0.1, 16, 128]} />
<meshStandardMaterial emissive="#f472b6" emissiveIntensity={2.5} />
</mesh>
</>
);
}
export default function App() {
return (
<Canvas camera={{ position: [4, 3, 6], fov: 45 }}>
<color attach="background" args={['#050516']} />
<Environment preset="studio" />
<Rings />
<OrbitControls enableDamping />
<EffectComposer>
<Bloom intensity={0.9} luminanceThreshold={0.6} />
</EffectComposer>
</Canvas>
);
}
The vanilla equivalent was ~150 lines. R3F compresses setup, cleanup, and composition.
No renderer.dispose(). No resize listeners. No shadow-map camera tuning (well,
you can, but drei's defaults are usually right).
Loading glTF models — useGLTF
import { useGLTF } from '@react-three/drei';
function Character() {
const { scene, animations } = useGLTF('/robot.glb');
return <primitive object={scene} />;
}
// Preload during app startup — no load flash later:
useGLTF.preload('/robot.glb');
For animations, drei's useAnimations:
import { useGLTF, useAnimations } from '@react-three/drei';
import { useEffect, useRef } from 'react';
function Character({ state }) {
const group = useRef();
const { scene, animations } = useGLTF('/robot.glb');
const { actions } = useAnimations(animations, group);
useEffect(() => {
actions[state]?.reset().fadeIn(0.4).play();
return () => actions[state]?.fadeOut(0.4);
}, [state, actions]);
return <primitive ref={group} object={scene} />;
}
Article 05's state machine becomes a React useState + the component above.
Clean, explicit, re-renders only when state changes.
Post-processing with React
@react-three/postprocessing wraps the Article 07 composer as components:
<EffectComposer>
<Bloom intensity={0.9} luminanceThreshold={0.6} />
<DepthOfField focusDistance={0.01} focalLength={0.02} />
<Noise opacity={0.02} />
<Vignette eskil={false} offset={0.1} darkness={0.6} />
</EffectComposer>
Same passes, same pass-ordering rules from Article 07, but as JSX children of the composer. Toggling a pass = conditional render.
Performance tips specific to R3F
1. Memo geometries and materials
const geom = useMemo(() => new THREE.BoxGeometry(), []);
const mat = useMemo(() => new THREE.MeshStandardMaterial(), []);
return <mesh geometry={geom} material={mat} />;
JSX <boxGeometry /> is fine for one-off meshes. For hundreds of the
same shape, memoize once and reuse.
2. Use drei <Instances>
<Instances limit={10000}>
<boxGeometry />
<meshStandardMaterial />
{positions.map((p, i) => <Instance key={i} position={p} />)}
</Instances>
This is R3F's wrapper for InstancedMesh from Article 09. One draw call for N
instances, authored like component trees.
3. Render on demand
<Canvas frameloop="demand">
The loop only runs when something invalidates (controls move, state changes). For static scenes with occasional interaction, this drops idle CPU to zero.
Shipping — the deployment recipes
You've built it. Now put it on the internet. Three paths, in order of simplicity:
1. Vercel (the default)
If you used Next.js or Vite:
npm i -g vercel
vercel # first run: answers defaults
vercel --prod # subsequent deploys
For a pure HTML site like this one, drop the folder on Vercel:
vercel --cwd ./threejs-tutorials
Done. Global CDN. Free tier fine for hobby. Custom domain in 2 minutes.
2. Cloudflare Pages
Cheaper at scale (generous free tier, no bandwidth charges). Connect a GitHub repo → Cloudflare auto-deploys on push. For drag-and-drop:
npm i -g wrangler
wrangler pages deploy ./threejs-tutorials --project-name=my-3d-site
3. Self-host on S3 + CloudFront
For maximum control:
aws s3 sync ./threejs-tutorials s3://my-bucket/ --delete
aws cloudfront create-invalidation --distribution-id XYZ --paths "/*"
Useful if you already live in AWS. Otherwise Vercel or Cloudflare is less operational overhead.
Asset hosting (.glb, .hdr, textures)
Big binary files don't belong in your site bundle. Host them separately:
- Cloudflare R2 — S3-compatible, zero egress fees. Best for glTF + HDR.
- AWS S3 + CloudFront — classic, globally cached.
- Vercel Blob — if you're already on Vercel.
Then in your code:
useGLTF('https://assets.mysite.com/robot.glb');
Caching headers — the one thing you must get right
3D assets are big, immutable (once published), and expensive to download twice. Set aggressive cache headers:
Cache-Control: public, max-age=31536000, immutable
On Cloudflare Pages, add this to _headers:
/*.glb
Cache-Control: public, max-age=31536000, immutable
/*.hdr
Cache-Control: public, max-age=31536000, immutable
/*.ktx2
Cache-Control: public, max-age=31536000, immutable
Use file-name hashes (robot.a2f9b.glb) so you can keep aggressive cache and
still cache-bust on change.
Compress everything
- Run glTF-Transform on every model:
gltf-transform optimize in.glb out.glb. - Convert textures inside glTFs to KTX2 with
--texture-compress=ktx2. - Enable Brotli on your CDN for HTML/JS/CSS/glTF JSON.
Common first-time pitfalls (R3F-specific)
- Scene flashes on re-render. You created a new geometry/material every render. Memoize with
useMemo, or declare as JSX so the reconciler reuses. - useFrame not firing. The component isn't mounted inside a
<Canvas>. useFrame only works within the R3F tree. - ref.current is null in useEffect. React 18 strict mode double-mounts. Do work in useFrame or a deferred effect instead.
- Cannot read properties of undefined (reading 'rotation'). useFrame ran before ref attached. Guard with
if (!ref.current) return;. - Model loads but invisible. It might be massive or at wrong origin. Use
<Bounds fit>from drei to auto-frame it. - Cannot import R3F from CDN. esm.sh deps mismatch — ensure all three of react, react-dom, three match exact versions with
?deps=.
Exercises
- Port the Article 05 state-machine character to R3F:
<Gltf />+useAnimations+ a React state for the active clip. - Deploy the Article 07 neon scene to Vercel. Add it to your portfolio.
- Host a large glTF on R2 with the
max-age=31536000, immutableheader. Measure the second-visit load time in DevTools.
What's after "what's next"
Future directions, not promised but worth exploring:
- WebXR — Quest 3 is the first consumer VR device that makes the web browser feasible. R3F +
@react-three/xr. - Multiplayer —
colyseus,y-three,liveblocksfor shared 3D spaces. - Physics —
@react-three/rapierfor fast, stable physics bodies. - GPGPU / compute — TSL Compute Nodes for particle systems and fluid sims.
- Procedural worlds — noise-based terrain, marching cubes, voxel engines.
- Character rigging — IK (CCDIK, FABRIK from drei), ragdoll.
If you build something from this series, I'd love to see it. Tag the thread.