3DAssets.dev

React guide

Free 3D models in React Three Fiber

useGLTF, preloading, Suspense boundaries and animation hooks, with the patterns that keep a React scene fast once you are drawing the same model two hundred times.

3DAssets.dev4 min read

React Three Fiber turns a three.js scene into a component tree, and drei supplies the loader hook, so getting a model on screen is shorter here than in plain three.js. The trade is that the caching is implicit, and that catches people out the first time they render the same model twice.

The basic component

1import { useGLTF } from '@react-three/drei'
2
3export function OakTree(props) {
4 const { scene } = useGLTF('https://cdn.3dassets.dev/assets/28312/v1/model.glb')
5 return <primitive object={scene} {...props} />
6}
7useGLTF.preload('https://cdn.3dassets.dev/assets/28312/v1/model.glb')

Two details are doing more than they look. Spreading props onto the primitive means the caller positions the tree rather than the component deciding, which is what makes it reusable. The preload call outside the component starts the download when the module is imported instead of when the component first renders, which is usually several hundred milliseconds earlier.

Every asset page on 3DAssets.dev generates this component with its own URL and a name derived from the model, so you can copy it rather than write it. The URL above is a young oak; the animated one further down is a supply crate.

Suspense, because the hook suspends

useGLTF suspends while the file downloads. Without a boundary above it, the first render throws:

1import { Canvas } from '@react-three/fiber'
2import { Suspense } from 'react'
3
4export function Scene() {
5 return (
6 <Canvas camera={{ position: [8, 5, 8], fov: 50 }}>
7 <ambientLight intensity={0.6} />
8 <directionalLight position={[5, 10, 5]} intensity={1.5} />
9 <Suspense fallback={null}>
10 <OakTree position={[0, 0, 0]} />
11 </Suspense>
12 </Canvas>
13 )
14}

A null fallback is the right default inside a Canvas: a DOM spinner cannot be rendered into a WebGL scene, and the usual place for loading feedback is outside the Canvas or through drei's useProgress.

Put the boundary around a group of models rather than each one individually if you would rather they appear together than one at a time. Both are defensible; popping in one by one just tends to read as broken.

Animated models

drei's useAnimations builds the mixer and drives it on every frame, so there is no update call to forget:

1import { useGLTF, useAnimations } from '@react-three/drei'
2import { useEffect } from 'react'
3
4export function SupplyCrate(props) {
5 const { scene, animations } = useGLTF('https://cdn.3dassets.dev/assets/27447/v1/model.glb')
6 const { actions, names } = useAnimations(animations, scene)
7 useEffect(() => {
8 const action = actions[names[0]]
9 action?.reset().play()
10 return () => { action?.stop() }
11 }, [actions, names])
12 return <primitive object={scene} {...props} />
13}
14useGLTF.preload('https://cdn.3dassets.dev/assets/27447/v1/model.glb')

Clips are named, so once you know what a model carries you can address one directly. A crate publishes open and close:

1useEffect(() => {
2 const action = actions[isOpen ? 'open' : 'close']
3 action?.reset().setLoop(LoopOnce, 1).play()
4 if (action) action.clampWhenFinished = true
5}, [actions, isOpen])

clampWhenFinished is what stops the lid snapping back to the closed pose the moment the clip ends.

Rendering the same model many times

This is the one that surprises people. useGLTF caches by URL and hands back the same object every time, and a three.js object can only occupy one position in the scene graph. Render OakTree twice and you do not get two trees: you get one tree, in the second position.

For a handful of copies, clone per instance:

1import { useGLTF } from '@react-three/drei'
2import { useGraph } from '@react-three/fiber'
3import { SkeletonUtils } from 'three-stdlib'
4import { useMemo } from 'react'
5
6export function OakTree(props) {
7 const { scene } = useGLTF('https://cdn.3dassets.dev/assets/28312/v1/model.glb')
8 const clone = useMemo(() => SkeletonUtils.clone(scene), [scene])
9 return <primitive object={clone} {...props} />
10}

SkeletonUtils.clone rather than scene.clone() because it handles skinned meshes correctly. The geometry and materials are still shared, so the clone is cheap: what you are duplicating is the node hierarchy.

For a forest, cloning stops being the right answer somewhere around a hundred copies, because each one is its own draw call. Use drei's Instances and Instance instead, which draw the lot in one:

1import { Instances, Instance, useGLTF } from '@react-three/drei'
2
3export function Forest({ positions }) {
4 const { nodes, materials } = useGLTF('https://cdn.3dassets.dev/assets/28312/v1/model.glb')
5 const mesh = Object.values(nodes).find((n) => n.isMesh)
6 return (
7 <Instances geometry={mesh.geometry} material={Object.values(materials)[0]}>
8 {positions.map((p, i) => (
9 <Instance key={i} position={p} rotation={[0, Math.random() * Math.PI * 2, 0]} />
10 ))}
11 </Instances>
12 )
13}

Instancing needs one geometry and one material, so it suits a model that is a single mesh. Check the mesh and material counts before you plan around it: they are published on every asset, and the young oak above is two meshes with two materials, so it needs either a small amount of work or a different model.

Environment lighting without a remote fetch

drei's Environment component with a preset is the quickest way to make PBR materials look right, and it downloads a large HDR from a third-party CDN every time it mounts. That is a slow first paint and an uptime dependency you did not choose. Generate the environment locally instead:

1import { Environment } from '@react-three/drei'
2
3<Environment preset="city" /> // fetches an HDR from a remote CDN

Prefer three's RoomEnvironment, prefiltered in your own app, or host the HDR yourself if you need a specific look. It is the single biggest saving available on most React Three Fiber pages that feel slow to appear.

Keeping the bundle honest

three.js is large, and a React page that mounts a Canvas at the top level ships all of it in the initial JavaScript whether or not the viewer scrolls to the scene. Two things help:

  • Import the scene component dynamically so three.js lands in a separate chunk, and show a still image until it is ready. On a page with one model this is the difference between roughly 550 KB and roughly 290 KB of compressed JavaScript.
  • Keep any poster or placeholder component free of three.js imports. One stray import from a placeholder pulls the whole library back into the initial bundle, and it is invisible until you look at a bundle report.

Next

The three.js guide covers the same ground without React, including scale conventions and why a model renders black, both of which apply here unchanged. If you want an assistant assembling these scenes for you, connect it to the catalogue.

Common questions

How do I load a GLB model in React Three Fiber?
Use the useGLTF hook from @react-three/drei with the model URL, then render the returned scene with a primitive element. Wrap it in a Suspense boundary, because the hook suspends while the file downloads, and call useGLTF.preload with the same URL so the fetch starts before the component mounts.
Why does my model disappear when I render it twice in React Three Fiber?
Because useGLTF caches by URL and returns the same object each time, and a three.js object can only sit at one place in the scene graph. Rendering it twice moves it rather than duplicating it. Clone the scene for each instance with SkeletonUtils.clone, or use an instanced mesh when you need many copies.
Do I need to convert GLB models to JSX components?
No. gltfjsx is useful when you want to address individual meshes or swap materials per part, but rendering the loaded scene through a primitive element works for the common case and needs no build step. Start with primitive and reach for gltfjsx when you actually need per-mesh control.
How do I play an animation from a GLB in React Three Fiber?
Pass the animations array and the scene to drei's useAnimations hook, then play a named action in an effect. The hook creates and updates the mixer for you on every frame, so there is no manual update call to forget.

Keep reading

three.js guide

How to load a free GLB model in three.js

A working GLTFLoader setup, straight from a CDN URL with no build step, plus animation, sensible scaling and the four mistakes that account for most blank screens.

AI setup

Give your AI assistant a 3D model library

An AI coding assistant can write a three.js scene in seconds and then fill it with grey cubes, because it has no models and no way to find any. Connecting it to a catalogue fixes that in one line.