Three.js From Zero · Article 10

React Three Fiber + Shipping

← threejs-from-zero Article 10 · finale
Article 10 · Three.js From Zero

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.

0 fps
R3F · drei · no build
drag to orbit · scroll to zoom

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

LayerPurpose
<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:

  • delta comes as seconds already — no t * 0.001 nonsense.
  • state has state.clock, state.camera, state.scene, state.gl (the renderer). You rarely need it.
  • ref.current is 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 helperReplaces 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

  1. Port the Article 05 state-machine character to R3F: <Gltf /> + useAnimations + a React state for the active clip.
  2. Deploy the Article 07 neon scene to Vercel. Add it to your portfolio.
  3. Host a large glTF on R2 with the max-age=31536000, immutable header. Measure the second-visit load time in DevTools.

Series complete 🎉

Ten articles. Ten live demos. One deployable scene.

From Scene/Camera/Renderer to shipping R3F in production — you now know everything needed to build real 3D on the web.

← back to the series index

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.
  • Multiplayercolyseus, y-three, liveblocks for shared 3D spaces.
  • Physics@react-three/rapier for 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.