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.

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'23export 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'34export 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'34export 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 = true5}, [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'56export 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'23export 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'23<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.

