Appearance
Pitfalls
FreshThe most important gotcha in three.js is that creating objects can be expensive. Think twice before you mount/unmount things! Every material or light that you put into the scene has to compile, every geometry you create will be processed. Share materials and geometries if you can, either in global scope or locally:
jsx
const geom = useMemo(() => new BoxGeometry(), [])
const mat = useMemo(() => new MeshBasicMaterial(), [])
return items.map(i => <mesh geometry={geom} material={mat} ... />)Try to use instancing as much as you can when you need to display many objects of a similar type!
Avoid setState in Loops
TLDR: don't — mutate inside useFrame!
- three.js has a render-loop, it does not work like the DOM does. Fast updates are carried out in
useFrameby mutation.useFrameis your per-component render-loop. - It is not enough to set values in succession — you need frame deltas. Instead of
position.x += 0.1considerposition.x += deltaor your project will run at different speeds depending on the end-user's system. - You might be tempted to
setStateinsideuseFramebut there is no reason to. You would only complicate something as simple as an update by routing it through React's scheduler.
setState in Loops is Bad
jsx
useEffect(() => {
const interval = setInterval(() => setX((x) => x + 0.1), 1)
return () => clearInterval(interval)
}, [])setState in useFrame is Bad
jsx
const [x, setX] = useState(0)
useFrame(() => setX((x) => x + 0.1))
return <mesh position-x={x} />setState in Fast Events is Bad
jsx
<mesh onPointerMove={(e) => setX((x) => e.point.x)} />Instead, Just Mutate — Use Deltas
In general you should prefer useFrame. Consider mutating props safe as long as the component is the only entity that mutates. Use deltas instead of fixed values so that your app is refresh-rate independent and runs at the same speed everywhere!
jsx
const meshRef = useRef()
useFrame((state, delta) => (meshRef.current.position.x += delta))
return <mesh ref={meshRef} />Same goes for events — use references:
jsx
<mesh onPointerMove={(e) => (ref.current.position.x = e.point.x)} />If you must use intervals, use references as well — but keep in mind this is not refresh-rate independent:
jsx
useEffect(() => {
const interval = setInterval(() => ref.current.position.x += 0.1, 1)
return () => clearInterval(interval)
}, [])Handle Animations in Loops
The frame loop is where you should place your animations. Use lerp or damp.
Use lerp + useFrame
jsx
function Signal({ active }) {
const meshRef = useRef()
useFrame((state, delta) => {
meshRef.current.position.x = THREE.MathUtils.lerp(meshRef.current.position.x, active ? 100 : 0, 0.1)
})
return <mesh ref={meshRef} />Or react-spring
jsx
import { a, useSpring } from '@react-spring/three'
function Signal({ active }) {
const { x } = useSpring({ x: active ? 100 : 0 })
return <a.mesh position-x={x} />React-spring and framer-motion are popular alternatives — they have their own frame loops and animate outside of React.
Do Not Bind to Fast State Reactively
Using state-managers and selective state is fine, but not for updates that happen rapidly.
Don't Bind Reactive Fast-State
jsx
import { useSelector } from 'react-redux'
// Assuming that x gets animated inside the store 60fps
const x = useSelector((state) => state.x)
return <mesh position-x={x} />Fetch State Directly
For instance using Zustand (same in Redux et al):
jsx
useFrame(() => (ref.current.position.x = api.getState().x))
return <mesh ref={ref} />Don't Mount Indiscriminately
In three.js it is very common to not re-mount at all. Buffers and materials get re-initialized/compiled which can be expensive.
Avoid Mounting at Runtime
jsx
{stage === 1 && <Stage1 />}
{stage === 2 && <Stage2 />}
{stage === 3 && <Stage3 />}Consider Using Visibility Instead
jsx
<Stage1 visible={stage === 1} />
<Stage2 visible={stage === 2} />
<Stage3 visible={stage === 3} />
function Stage1(props) {
return (
<group {...props}>
...Use startTransition for Expensive Ops
React 18 introduces startTransition and useTransition APIs to defer and schedule work. Use these to de-prioritize expensive operations.
jsx
import { useTransition } from 'react'
import { Points } from '@react-three/drei'
const [isPending, startTransition] = useTransition()
const [radius, setRadius] = useState(1)
const positions = calculatePositions(radius)
const colors = calculateColors(radius)
const sizes = calculateSizes(radius)
<Points
positions={positions}
colors={colors}
sizes={sizes}
onPointerOut={() => {
startTransition(() => {
setRadius(prev => prev + 1)
})
}}
>
<meshBasicMaterial vertexColors />
</Points>Don't Re-Create Objects in Loops
Try to avoid creating too much effort for the garbage collector. Re-pool objects when you can!
Bad News for the GC
This creates a new vector 60 times a second:
jsx
useFrame(() => {
ref.current.position.lerp(new THREE.Vector3(x, y, z), 0.1)
})Better — Re-Use Object
Set up re-used objects in global or local space:
jsx
function Foo(props) {
const vec = new THREE.Vector3()
useFrame(() => {
ref.current.position.lerp(vec.set(x, y, z), 0.1)
})useLoader Instead of Plain Loaders
Threejs loaders give you the ability to load async assets but if you do not re-use assets it can quickly become problematic.
No Re-Use is Bad for Perf
This re-fetches and re-parses for every component instance:
jsx
function Component() {
const [texture, set] = useState()
useEffect(() => void new TextureLoader().load(url, set), [])
return texture ? (
<mesh>
<sphereGeometry />
<meshBasicMaterial map={texture} />
</mesh>
) : null
}Cache and Re-Use Objects
jsx
function Component() {
const texture = useLoader(TextureLoader, url)
return (
<mesh>
<sphereGeometry />
<meshBasicMaterial map={texture} />
</mesh>
)
}Regarding GLTFs, try to use GLTFJSX as much as you can — this will create immutable JSX graphs which allow you to even re-use full models.