Skip to content

Fix: React Three Fiber Not Working — Canvas Blank, Models Not Loading, or Performance Dropping

FixDevs · (Updated: )

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 visible

Or a GLTF model fails to load:

Error: Could not load /model.glb: Unexpected token < in JSON at position 0

Or the scene runs at 10 FPS:

Smooth on desktop, slideshow on mobile

Production 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 visiblemeshStandardMaterial and meshPhysicalMaterial are physically-based materials that require lights in the scene. Without a light source, everything renders black. Only meshBasicMaterial ignores lighting.
  • The Canvas needs explicit dimensionsCanvas fills its parent container. If the parent has zero height (common with div in flexbox), the canvas is invisible. Give the parent a defined height.
  • GLTF files must be served as static assets — placing a .glb file in the wrong directory (e.g., src/ instead of public/) 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles