EmpireUI
Get Pro
← Blog9 min read#react three fiber#three.js#r3f

React Three Fiber in 2026: 3D Scenes, Shaders and Performance

Master React Three Fiber in 2026: build 3D scenes, write custom GLSL shaders, and hit 60fps on mobile with practical performance techniques.

abstract 3D geometric shapes rendered with colorful WebGL lighting effects

Why R3F Still Wins in 2026

Three.js has been around since 2010. React Three Fiber — the reconciler that lets you write Three.js as JSX — hit v8 in 2023 and hasn't looked back. In 2026, it's the default way most React devs reach for 3D on the web, and for good reason.

Honestly, the selling point isn't syntax sugar. It's that R3F plugs your 3D scene directly into the React component model. You get props, hooks, context, Suspense — the whole tree. That means your 3D objects react to state exactly the way your UI does, no manual .add() / .remove() bookkeeping required.

That said, there's a real cost to this abstraction. Reconciliation overhead exists. If you're thrashing the scene graph on every frame, R3F will hurt you. But for the vast majority of product UIs — landing pages, interactive demos, UI components with a 3D twist — the tradeoffs are overwhelmingly positive.

Quick aside: the ecosystem around R3F has exploded. @react-three/drei alone has 80+ helpers covering cameras, controls, shaders, HTML overlays, and loaders. You almost never need raw Three.js anymore unless you're doing something truly exotic.

Setting Up a Scene the Right Way

Install the stack: npm install three @react-three/fiber @react-three/drei. As of r3f v8.17, the peer dep is Three.js r168+. Pin your Three.js version in package.json — minor Three.js bumps have broken shader APIs before.

Here's the minimal scene that doesn't shoot you in the foot later: ``tsx import { Canvas } from '@react-three/fiber' import { OrbitControls, Environment } from '@react-three/drei' export function Scene() { return ( <Canvas camera={{ fov: 60, near: 0.1, far: 100, position: [0, 2, 5] }} gl={{ antialias: true, powerPreference: 'high-performance' }} dpr={[1, 2]} > <OrbitControls /> <Environment preset="city" /> <mesh> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="hotpink" /> </mesh> </Canvas> ) } ``

Two things worth calling out. First, dpr={[1, 2]} caps the device pixel ratio at 2 — going higher on a 3x display burns GPU for zero visible gain at normal viewing distance. Second, powerPreference: 'high-performance' tells the browser to prefer the discrete GPU on multi-GPU machines like MacBooks. Leave it out and you may silently land on the integrated GPU.

The camera prop is declarative but gets applied once at mount. If you need to animate the camera imperatively later, grab a ref via useThree() — don't fight the prop system.

Worth noting: <Canvas> sets position: relative and fills its parent by default. Wrap it in a sized div, not the other way around. A lot of 'my canvas is 0px tall' bugs come from forgetting this.

Writing Custom GLSL Shaders in R3F

Standard materials take you far, but at some point you want something that doesn't exist in Three's built-in library. That's when you write GLSL. R3F doesn't abstract this away — you drop into raw shader code, and that's actually fine.

The cleanest pattern is shaderMaterial from drei: ``tsx import { shaderMaterial } from '@react-three/drei' import { extend, useFrame } from '@react-three/fiber' import { useRef } from 'react' import * as THREE from 'three' const WaveMaterial = shaderMaterial( { uTime: 0, uColor: new THREE.Color(0.2, 0.5, 1.0) }, // vertex shader varying vec2 vUv; uniform float uTime; void main() { vUv = uv; vec3 pos = position; pos.z += sin(pos.x * 3.0 + uTime) * 0.1; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } , // fragment shader uniform vec3 uColor; varying vec2 vUv; void main() { gl_FragColor = vec4(uColor * vUv.x, 1.0); } ) extend({ WaveMaterial }) export function WaveMesh() { const matRef = useRef<any>() useFrame(({ clock }) => { if (matRef.current) matRef.current.uTime = clock.getElapsedTime() }) return ( <mesh> <planeGeometry args={[4, 4, 32, 32]} /> <waveMaterial ref={matRef} /> </mesh> ) } ``

The extend call is the magic — it registers your custom material as a JSX element. After that you use it like any built-in Three material. Uniforms become props. You don't need a class, you don't need onBeforeCompile, and you don't need to manually call needsUpdate.

In practice, the uTime uniform pattern is something you'll use in literally every animated shader. The clock.getElapsedTime() in useFrame is the idiomatic way — don't do Date.now() / 1000 inside a render loop.

One more thing — if you want to extend a built-in material (say, add a custom displacement to MeshStandardMaterial while keeping PBR lighting), use onBeforeCompile. It's a lower-level escape hatch but gives you access to Three's existing shader chunks so you're not reimplementing lighting from scratch.

Performance: Hitting 60fps Without Losing Your Mind

3D on the web is brutal on mobile. An iPhone 14 GPU is genuinely fast, but it's not a 4090. Every decision you make about geometry count, draw calls, and texture size compounds. Here's what actually moves the needle.

Instancing is the single biggest win. If you're rendering the same geometry 100 times — particles, repeated decorative elements, grids — use <InstancedMesh> instead of 100 separate <mesh> components. This collapses 100 draw calls into 1: ``tsx import { useRef, useMemo } from 'react' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' export function Particles({ count = 500 }) { const meshRef = useRef<THREE.InstancedMesh>(null) const dummy = useMemo(() => new THREE.Object3D(), []) useFrame(({ clock }) => { const t = clock.getElapsedTime() for (let i = 0; i < count; i++) { dummy.position.set( Math.sin(i * 0.5 + t) * 3, Math.cos(i * 0.3 + t) * 3, (i / count) * 10 - 5 ) dummy.scale.setScalar(0.05) dummy.updateMatrix() meshRef.current?.setMatrixAt(i, dummy.matrix) } if (meshRef.current) meshRef.current.instanceMatrix.needsUpdate = true }) return ( <instancedMesh ref={meshRef} args={[undefined, undefined, count]}> <sphereGeometry args={[1, 8, 8]} /> <meshStandardMaterial color="white" /> </instancedMesh> ) } ``

Notice the args={[1, 8, 8]} on the sphere — 8 segments, not 32. At 0.05 scale nobody can tell the difference, but the vertex count drops from 2048 to 128 per instance. Across 500 instances that's a lot.

Texture budgets matter. Compress everything with KTX2/Basis. Use useKTX2 from drei or run textures through basisu at build time. A 512×512 compressed texture can cost 30% of what an uncompressed 512×512 PNG costs in GPU memory — and GPU memory pressure kills mobile frame rates faster than anything.

Look, the frameloop='demand' prop on <Canvas> is underused. It stops R3F from rendering every single animation frame and only re-renders when something changes. If your scene is mostly static with occasional interaction, this is free performance — on some pages I've seen it cut GPU usage by 70%. Combine it with invalidate() from useThree to trigger renders manually when you need them.

Integrating R3F with Your Existing React UI

The awkward part of R3F isn't Three.js — it's making your 3D scene feel like it belongs in a web page. Canvas elements sit in a separate rendering context, and mixing HTML with WebGL has historically been a pain. Drei's <Html> component solves most of this.

import { Html } from '@react-three/drei'

function AnnotatedCube() {
  return (
    <mesh position={[0, 0, 0]}>
      <boxGeometry />
      <meshStandardMaterial color="royalblue" />
      <Html position={[0.6, 0.6, 0.6]} distanceFactor={1.5}>
        <div className="bg-white/80 backdrop-blur px-2 py-1 rounded text-xs">
          Product v2.4
        </div>
      </Html>
    </mesh>
  )
}

The <Html> component projects DOM elements into 3D space. It handles occlusion (via occlude prop) and scales with distance. You can put any React component in there — forms, buttons, tooltips. It's genuinely good. That said, if you're overlaying a lot of HTML, watch your CSS will-change budget — too many promoted layers and compositing gets expensive.

If your 3D scene needs to respond to global app state, just use your existing state manager — Zustand, Jotai, Redux, whatever. R3F is React. useStore() works inside useFrame() the same way it works anywhere else. The one gotcha: useFrame runs outside React's render cycle, so if you update state inside it (e.g. writing to a Zustand store on every frame) you'll cause React re-renders every frame. Use refs for frame-local data; push to state only for discrete events.

For landing pages and marketing components with a 3D flair, you might also look at how Empire UI's aurora and vaporwave effects handle layered backgrounds — those are CSS-based, which sidesteps the Canvas overhead entirely for decorative backgrounds. Sometimes the right answer is not WebGL.

Quick aside: @react-three/postprocessing wraps pmndrs/postprocessing for bloom, SSAO, chromatic aberration, and more. It's worth it for visual polish. Just keep an eye on the multi-pass cost — stacking 4 post effects on a mobile device can easily cut your frame rate in half.

Suspense, Lazy Loading and Asset Strategy

GLTF models are the main thing you'll be loading. The useGLTF hook from drei handles it with caching and Suspense out of the box: ``tsx import { useGLTF } from '@react-three/drei' import { Suspense } from 'react' function Model() { const { scene } = useGLTF('/models/chair.glb') return <primitive object={scene} /> } export function Scene() { return ( <Canvas> <Suspense fallback={null}> <Model /> </Suspense> </Canvas> ) } ``

Call useGLTF.preload('/models/chair.glb') at the module level (outside the component) to kick off the fetch before the component even mounts. On a 200ms connection this is the difference between a model that pops in vs one that's already there when the canvas appears.

For model optimization, run your GLBs through gltf-transform optimize before shipping. It applies Draco compression, merges geometries, and can get a 3MB model down to 400KB without any visual difference at typical viewport sizes. This is not optional for production — it's table stakes.

Worth noting: if you're building a component library or template system with 3D elements, you can check what Empire UI templates do with animated backgrounds — they show how to balance visual impact against bundle weight. Sometimes a CSS glassmorphism effect over a static scene costs less than a full WebGL scene and looks just as good for marketing contexts.

One more thing — error boundaries. Three.js GL context errors can crash your whole tree silently in production. Wrap your <Canvas> in an error boundary that gracefully falls back to a static image or a CSS animation. WebGL isn't supported in every environment (some corporate proxies block it), and you don't want a blank page.

Debugging and the R3F Dev Workflow

R3F's debugging experience has gotten much better. The browser's WebGL inspector is your first stop — Spector.js is the extension you want. It captures a single frame and shows you every draw call, shader, and texture. It's how you figure out why you have 47 draw calls when you expected 5.

Inside the React tree, <Stats> from drei gives you an FPS/memory overlay in development. Add it directly in your Canvas and remove it before production via an env flag: ``tsx import { Stats } from '@react-three/drei' <Canvas> {process.env.NODE_ENV === 'development' && <Stats />} {/* rest of scene */} </Canvas> ``

For unit-ish testing, the @react-three/test-renderer package lets you render R3F scenes headlessly. It's not perfect — there's no actual GPU, so anything shader-dependent is hard to test — but for asserting that scene graph structure is correct after state changes, it works fine.

Honestly, the biggest debugging skill in R3F isn't tooling — it's learning to isolate. If something's wrong, strip the scene down to a single mesh with a <meshBasicMaterial> (no lighting dependency) and build back up. Three.js silent failures are common when you pass wrong argument types to geometry constructors, and React's error messages don't always surface them clearly.

If you're hitting infinite re-render loops, check for object literals in JSX props. <mesh position={[0,0,0]}> creates a new array every render. Use useMemo for object/array values passed as 3D props, or use the tuple shorthand that R3F accepts natively — it handles the conversion internally and checks referential equality correctly.

FAQ

Does React Three Fiber work with Next.js App Router?

Yes, but you need to mark Canvas components with 'use client' since WebGL requires browser APIs. Dynamic imports with ssr: false are also an option if you want to avoid any SSR attempts entirely.

What's the difference between useFrame and useEffect in R3F?

useFrame runs on every render tick before the scene is drawn — it's your animation loop. useEffect runs after React commits to the DOM and isn't synced to the render loop, so never drive animations from it.

How do I handle WebGL context loss in production?

Listen for the webglcontextlost event on the canvas element and call event.preventDefault() to attempt recovery. R3F doesn't do this automatically yet — wrap your Canvas in an error boundary as a fallback.

Is React Three Fiber slower than raw Three.js?

By a small margin for very large scenes, but rarely in a way that matters. The reconciler overhead is a few milliseconds per frame at most. If you're CPU-bound in JS, profile first — nine times out of ten the bottleneck is geometry or draw calls, not R3F itself.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Three.js with React: Particles, Blobs and Interactive 3D Scenesreact-three-fiber + Drei: Cameras, Controls, Helpers and Loadersreact-three-fiber Intro: 3D Scenes in React With Three.jsTheatre.js: Visual Animation Editor for React and Three.js