Fix: React Three Fiber Not Working — Canvas Blank, Models Not Loading, or Performance Dropping
Part of: React & Frontend Errors
Quick Answer
How to fix React Three Fiber (R3F) issues — Canvas setup, loading 3D models with useGLTF, lighting, camera controls, animations with useFrame, post-processing, and Next.js integration.
The Problem
The Canvas component renders a black or empty box:
import { Canvas } from '@react-three/fiber';
function Scene() {
return (
<Canvas>
<mesh>
<boxGeometry />
<meshStandardMaterial color="red" />
</mesh>
</Canvas>
);
}
// Black square — nothing visibleOr a GLTF model fails to load:
Error: Could not load /model.glb: Unexpected token < in JSON at position 0Or the scene runs at 10 FPS:
Smooth on desktop, slideshow on mobileProduction Incident: The Brand Experience Breaks On Real Devices
Marketing pages and product configurators ship 3D scenes as their hero. When React Three Fiber breaks, it doesn’t return a stack trace to the user — it returns a black rectangle, or a phone that gets hot, or a tab that crashes after five minutes. Conversion on that page drops to near zero, and most users won’t tell you. They just bounce.
The classic field incident is GPU memory leakage. A scene that loads a fresh GLTF on every route change, never disposes the previous geometry or texture, and runs on a long-lived tab (a kiosk, a designer’s browser left open all day) leaks until the OS kills the tab. The symptom isn’t a JS error — it’s WebGL context loss, which renders as a frozen frame or a sudden black canvas. Always dispose: call useGLTF.clear() on unmount, drop textures with texture.dispose(), and never recreate materials inside useFrame.
A second incident pattern is the “looks fine on Mac, dies on Android” report. Mobile GPUs have much smaller texture limits (often 4096px max) and slower fill rate. A 4K baked normal map that took 5ms on a desktop GPU takes 80ms on a budget Android, and the page sits at 12 FPS. Catch this in CI with Lighthouse on mobile-throttled emulation, and gate texture sizes to 1024 or 2048 in your asset pipeline.
The third incident is the cold-load black screen on Next.js. R3F uses browser APIs (window, WebGL2RenderingContext), so importing it in a Server Component or in any code path that runs during SSR throws a build-time error or a runtime mismatch. Always wrap the Canvas in dynamic(() => import('./Scene'), { ssr: false }) and provide a loading placeholder. Without it, the hydration mismatch produces a flash of the placeholder, then a janky pop to 3D.
Why This Happens
React Three Fiber (R3F) is a React renderer for Three.js. It maps Three.js objects to JSX components, but the underlying 3D rendering concepts still apply:
- Meshes need lights to be visible —
meshStandardMaterialandmeshPhysicalMaterialare physically-based materials that require lights in the scene. Without a light source, everything renders black. OnlymeshBasicMaterialignores lighting. - The Canvas needs explicit dimensions —
Canvasfills its parent container. If the parent has zero height (common withdivin flexbox), the canvas is invisible. Give the parent a defined height. - GLTF files must be served as static assets — placing a
.glbfile in the wrong directory (e.g.,src/instead ofpublic/) causes the server to return an HTML 404 page, which Three.js tries to parse as JSON. - Three.js is CPU/GPU intensive — complex scenes with many meshes, high-resolution textures, or heavy post-processing overwhelm mobile GPUs. Performance optimization (instancing, LOD, frustum culling) is essential.
A deeper reason performance falls off is the React-to-Three-js mapping cost. Every JSX element in the scene becomes a Three.js object that R3F has to keep in sync with React state. If you re-render the entire scene on every state update — even small ones like a mouse hover — R3F walks the whole tree, diffs props, and updates Three.js objects. Use useFrame for animations that change per frame, keep React state out of the per-frame path, and memoize geometries and materials with useMemo so they aren’t recreated on every render.
A second reason for blank canvases is the renderer fallback. WebGL2 isn’t universal: some older Android devices, locked-down enterprise browsers, and security-conscious Linux setups only expose WebGL1 or no GPU at all. Three.js silently falls back to WebGL1 in many cases, but features that depend on WebGL2 (some post-processing, certain compressed texture formats) fail silently. Always check gl.capabilities.isWebGL2 and provide a graceful fallback or a “your device isn’t supported” message — don’t ship a broken hero that pretends to work.
A third reason scenes leak GPU memory is the disposal contract. React removes components from the tree; R3F automatically removes the corresponding Three.js objects from the scene graph; but the underlying GPU resources (geometries, textures, render targets) are not freed unless something calls dispose(). In an SPA that swaps scenes by route, this accumulates fast. Wire useEffect cleanup that calls geometry.dispose(), material.dispose(), and texture.dispose() for any resource you created manually. Drei’s helpers handle most cases, but custom ones won’t.
Fix 1: Basic Scene with Lighting
npm install @react-three/fiber @react-three/drei three
npm install -D @types/three'use client';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function Scene() {
return (
// Parent must have a defined height
<div style={{ width: '100%', height: '100vh' }}>
<Canvas
camera={{ position: [3, 3, 3], fov: 50 }}
shadows
>
{/* Lighting — essential for PBR materials */}
<ambientLight intensity={0.5} />
<directionalLight
position={[5, 5, 5]}
intensity={1}
castShadow
shadow-mapSize={[1024, 1024]}
/>
{/* Or use an environment map for realistic lighting */}
{/* <Environment preset="sunset" /> */}
{/* Objects */}
<mesh castShadow position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#4299e1" roughness={0.3} metalness={0.1} />
</mesh>
{/* Floor */}
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[10, 10]} />
<meshStandardMaterial color="#e2e8f0" />
</mesh>
{/* Camera controls — drag to rotate, scroll to zoom */}
<OrbitControls
enableDamping
dampingFactor={0.05}
minDistance={2}
maxDistance={20}
/>
</Canvas>
</div>
);
}Fix 2: Load 3D Models (GLTF/GLB)
# Place model files in the public/ directory
# public/models/robot.glb'use client';
import { Canvas } from '@react-three/fiber';
import { useGLTF, OrbitControls, Stage } from '@react-three/drei';
import { Suspense } from 'react';
// Preload the model
useGLTF.preload('/models/robot.glb');
function Robot(props: JSX.IntrinsicElements['group']) {
const { scene } = useGLTF('/models/robot.glb');
return <primitive object={scene} {...props} />;
}
// With typed nodes (after running gltfjsx)
// npx gltfjsx public/models/robot.glb --types --transform
function RobotDetailed(props: JSX.IntrinsicElements['group']) {
const { nodes, materials } = useGLTF('/models/robot.glb');
return (
<group {...props} dispose={null}>
<mesh
geometry={nodes.Body.geometry}
material={materials.Metal}
castShadow
/>
<mesh
geometry={nodes.Head.geometry}
material={materials.Metal}
position={[0, 1.5, 0]}
castShadow
/>
</group>
);
}
function ModelViewer() {
return (
<div style={{ width: '100%', height: '80vh' }}>
<Canvas shadows camera={{ position: [0, 2, 5], fov: 45 }}>
{/* Suspense for async model loading */}
<Suspense fallback={null}>
{/* Stage provides automatic lighting and centering */}
<Stage environment="city" intensity={0.5}>
<Robot scale={1} />
</Stage>
</Suspense>
<OrbitControls autoRotate autoRotateSpeed={1} />
</Canvas>
</div>
);
}Fix 3: Animations with useFrame
'use client';
import { Canvas, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
function RotatingCube() {
const meshRef = useRef<THREE.Mesh>(null);
// useFrame runs every frame (60fps)
useFrame((state, delta) => {
if (!meshRef.current) return;
meshRef.current.rotation.x += delta * 0.5;
meshRef.current.rotation.y += delta * 0.3;
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
// Animated with hover/click state
function InteractiveBox() {
const meshRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
const [clicked, setClicked] = useState(false);
useFrame((state, delta) => {
if (!meshRef.current) return;
// Smooth scale animation
const target = clicked ? 1.5 : 1;
meshRef.current.scale.lerp(new THREE.Vector3(target, target, target), delta * 5);
});
return (
<mesh
ref={meshRef}
onClick={() => setClicked(!clicked)}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={hovered ? 'hotpink' : 'orange'} />
</mesh>
);
}
// Animated GLTF model with mixer
import { useAnimations, useGLTF } from '@react-three/drei';
function AnimatedCharacter() {
const group = useRef<THREE.Group>(null);
const { scene, animations } = useGLTF('/models/character.glb');
const { actions } = useAnimations(animations, group);
useEffect(() => {
// Play the "walk" animation
actions['walk']?.reset().fadeIn(0.5).play();
return () => { actions['walk']?.fadeOut(0.5); };
}, [actions]);
return <primitive ref={group} object={scene} />;
}Fix 4: Text, HTML Overlays, and UI
import { Html, Text, Text3D, Float, Billboard } from '@react-three/drei';
// 3D text (using troika-three-text under the hood)
function TextExample() {
return (
<Text
position={[0, 2, 0]}
fontSize={0.5}
color="white"
anchorX="center"
anchorY="middle"
font="/fonts/Inter-Bold.woff"
>
Hello World
</Text>
);
}
// HTML overlay in 3D space
function HtmlOverlay() {
return (
<mesh position={[2, 1, 0]}>
<sphereGeometry args={[0.3]} />
<meshStandardMaterial color="red" />
<Html
distanceFactor={10}
position={[0, 0.5, 0]}
center
className="pointer-events-auto"
>
<div className="bg-white rounded-lg shadow-xl p-3 text-sm w-48">
<p className="font-bold">Info Point</p>
<p className="text-gray-600">Click for details</p>
</div>
</Html>
</mesh>
);
}
// Floating animation
function FloatingLogo() {
return (
<Float
speed={2}
rotationIntensity={0.5}
floatIntensity={1}
>
<mesh>
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
<meshStandardMaterial color="#8b5cf6" metalness={0.8} roughness={0.2} />
</mesh>
</Float>
);
}Fix 5: Next.js App Router Integration
// R3F uses browser APIs — must be client-only
// components/Scene.tsx
'use client';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import { Suspense } from 'react';
export function Scene() {
return (
<Canvas>
<Suspense fallback={null}>
<Environment preset="sunset" />
<mesh>
<boxGeometry />
<meshStandardMaterial color="blue" />
</mesh>
<OrbitControls />
</Suspense>
</Canvas>
);
}
// app/page.tsx — Server Component can import client Scene
import { Scene } from '@/components/Scene';
export default function Home() {
return (
<main>
<h1>3D Viewer</h1>
<div style={{ height: '600px' }}>
<Scene />
</div>
</main>
);
}
// Dynamic import for code splitting (optional)
import dynamic from 'next/dynamic';
const Scene = dynamic(() => import('@/components/Scene').then(m => m.Scene), {
ssr: false,
loading: () => <div style={{ height: '600px', background: '#111' }}>Loading 3D...</div>,
});Fix 6: Performance Optimization
import { useFrame } from '@react-three/fiber';
import { Instances, Instance, PerformanceMonitor, AdaptiveDpr } from '@react-three/drei';
// Instanced rendering — thousands of objects efficiently
function InstancedBoxes({ count = 1000 }) {
const data = useMemo(() =>
Array.from({ length: count }, () => ({
position: [
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20,
] as [number, number, number],
rotation: [Math.random() * Math.PI, Math.random() * Math.PI, 0] as [number, number, number],
scale: 0.2 + Math.random() * 0.3,
})), [count]
);
return (
<Instances limit={count}>
<boxGeometry />
<meshStandardMaterial color="#4299e1" />
{data.map((props, i) => (
<Instance key={i} position={props.position} rotation={props.rotation} scale={props.scale} />
))}
</Instances>
);
}
// Adaptive performance
function PerformantScene() {
return (
<Canvas>
{/* Auto-adjust DPR based on performance */}
<AdaptiveDpr pixelated />
{/* Monitor performance and adjust */}
<PerformanceMonitor
onIncline={() => console.log('Performance improving')}
onDecline={() => console.log('Performance declining')}
onChange={({ factor }) => {
// factor: 0 (bad) to 1 (good)
// Use to adjust quality dynamically
}}
/>
{/* Frustum culling is on by default */}
<mesh frustumCulled>
<boxGeometry />
<meshStandardMaterial />
</mesh>
</Canvas>
);
}Still Not Working?
Canvas is black — add lights. meshStandardMaterial requires at least an ambientLight or directionalLight. For quick results, use <Environment preset="sunset" /> from @react-three/drei, which provides image-based lighting.
Model shows “Unexpected token <” — the model file isn’t served correctly. Place .glb files in public/models/ and reference them as /models/robot.glb (without public/). If the dev server returns HTML instead of the binary file, the path is wrong.
Scene freezes or crashes on mobile — reduce complexity. Disable shadows (shadows={false} on Canvas), lower texture resolution, reduce polygon count, and use <AdaptiveDpr /> to lower the pixel ratio. For many objects, use <Instances> instead of individual <mesh> elements.
useFrame causes “hooks can only be called inside Canvas” — components using R3F hooks (useFrame, useThree, useGLTF) must be children of <Canvas>, not siblings. The Canvas creates a separate React reconciler — R3F hooks only work inside it.
Tab dies after a long session (“Aw, Snap!”) — the GPU memory is full. Every scene swap leaks geometry, materials, and textures unless they’re explicitly disposed. Add cleanup in useEffect: call geometry.dispose(), material.dispose(), and texture.dispose() on unmount. Use useGLTF.clear(path) to drop cached models. Watch renderer.info.memory.geometries in dev to confirm the count doesn’t grow on every navigation.
WebGL context lost without warning — the browser killed the context to reclaim resources, often after the tab went background or another GPU-heavy tab opened. Listen for the webglcontextlost event on the canvas and either reload the page or call restoreContext(). Drei’s <Loader /> and R3F’s onCreated give you a hook point to wire this up.
SSR hydration mismatch on first paint — R3F touched the DOM during render before hydration completed. Always import the Canvas with dynamic(() => import('./Scene'), { ssr: false }) so it never tries to render server-side. Provide a loading placeholder with the same dimensions to avoid layout shift when the real scene mounts.
For related animation and rendering issues, see Fix: Framer Motion Not Working, Fix: Next.js Hydration Failed, Fix: Next.js Image Optimization Error, and Fix: Node Heap Out of Memory.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: Auth.js (NextAuth) Not Working — Session Null, OAuth Callback Error, or CSRF Token Mismatch
How to fix Auth.js and NextAuth.js issues — OAuth provider setup, session handling in App Router and Pages Router, JWT vs database sessions, middleware protection, and credential provider configuration.