React Three Fiber

Note

It's assumed you have a base understanding of react-spring and react-three-fiber. If you're new to either, check out our getting started or alternatively, the r3f docs.

Introduction

In this guide we'll explore why react-spring is a valuable addition to your r3f project, working with the imperative API to create performant animation updates on objects in the scene graph and with the event system of the library to update parts of your scene that are not wrapped in our animated HOC. To work with react-spring and react-three-fiber you'll need to install the @react-spring/three package.

yarn add @react-spring/three

Why use the library?

A common question asked is why use react-spring with react-three-fiber when you can use the useFrame hook to update your meshes & objects instead without knowing another API. Well this is a great question, lets consider a simple use-case.

I have a distortion blob and I want to it to change color on click. You could do this with useFrame to perform frame by frame updates, useRef to access the material object and use the THREE.Color.lerp function, slowly incrementing by lerp value until the color is reached. But this then requires that I keep track of a THREE instance of Color which can lead to memory leaks if not handled correctly and not only that, but it would be a lot of code. Then you'd need to think about writing your own easing functions. With react-spring you can do this in a few lines of code.

import { useState } from 'react'
import { useSpring, animated } from '@react-spring/three'
import { MeshDistortMaterial } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
const AnimatedMeshDistortMaterial = animated(MeshDistortMaterial)
const MyScene = () => {
const [clicked, setClicked] = useState(false)
const springs = useSpring({
color: clicked ? '#569AFF' : '#ff6d6d',
})
const handleClick = () => setClicked(s => !s)
return (
<mesh onClick={handleClick}>
<sphereGeometry args={[1.5, 64, 32]} />
<AnimatedMeshDistortMaterial speed={5} distort={0.5} color={springs.color} />
</mesh>
)
}
export default function MyComponent() {
return (
<Canvas>
<ambientLight intensity={0.8} />
<pointLight intensity={1} position={[0, 6, 0]} />
<MyScene />
</Canvas>
)
}

Use the imperative API

In the example below, we use the power of the react-spring's api to lean into the imperative requirements of working with performant webGL scenes. The blob follows you round the canvas (lines 70-82) & scales on interaction (lines 46-56) without a single react render to cause these updates.

In addition we use the provide a function to the config prop instead of an object to deliver a more sticky config for the spring in general, but to give the blob a bouncy feel when the scale key is changed – lines 18-30.

Finally, because we're using the position of the mouse which can be considered a Vector2 and the position of a mesh is a Vector3 we use a custom interpolation via the to method of a SpringValue to interpolate the array, this can be seen on line 97.

Why not read more about it the imperative and interpolation APIs?

import { useRef, useEffect, useCallback } from 'react'
import { useSpring, animated } from '@react-spring/three'
import { Canvas, useThree } from '@react-three/fiber'
import { MeshDistortMaterial } from '@react-three/drei'
const AnimatedMeshDistortMaterial = animated(MeshDistortMaterial)
const MyScene = () => {
const isOver = useRef(false)
const { width, height } = useThree(state => state.size)
const [springs, api] = useSpring(
() => ({
scale: 1,
position: [0, 0],
color: '#ff6d6d',
config: key => {
switch (key) {
case 'scale':
return {
mass: 4,
friction: 10,
}
case 'position':
return { mass: 4, friction: 220 }
default:
return {}
}
},
}),
[]
)
const handleClick = useCallback(() => {
let clicked = false
return () => {
clicked = !clicked
api.start({
color: clicked ? '#569AFF' : '#ff6d6d',
})
}
}, [])
const handlePointerEnter = () => {
api.start({
scale: 1.5,
})
}
const handlePointerLeave = () => {
api.start({
scale: 1,
})
}
const handleWindowPointerOver = useCallback(() => {
isOver.current = true
}, [])
const handleWindowPointerOut = useCallback(() => {
isOver.current = false
api.start({
position: [0, 0],
})
}, [])
const handlePointerMove = useCallback(
e => {
if (isOver.current) {
const x = (e.offsetX / width) * 2 - 1
const y = (e.offsetY / height) * -2 + 1
api.start({
position: [x * 5, y * 2],
})
}
},
[api, width, height]
)
useEffect(() => {
window.addEventListener('pointerover', handleWindowPointerOver)
window.addEventListener('pointerout', handleWindowPointerOut)
window.addEventListener('pointermove', handlePointerMove)
return () => {
window.removeEventListener('pointerover', handleWindowPointerOver)
window.removeEventListener('pointerout', handleWindowPointerOut)
window.removeEventListener('pointermove', handlePointerMove)
}
}, [handleWindowPointerOver, handleWindowPointerOut, handlePointerMove])
return (
<animated.mesh onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} onClick={handleClick()} scale={springs.scale} position={springs.position.to((x, y) => [x, y, 0])}>
<sphereGeometry args={[1.5, 64, 32]} />
<AnimatedMeshDistortMaterial speed={5} distort={0.5} color={springs.color} />
</animated.mesh>
)
}
export default function MyComponent() {
return (
<Canvas>
<ambientLight intensity={0.8} />
<pointLight intensity={1} position={[0, 6, 0]} />
<MyScene />
</Canvas>
)
}

Working with spring events

Sometimes, it's necessary to sync the state of a spring with the an external source. This can be done with the event system built into react-spring.

Take the following example, we have multiple blobs on our screen that start in different places and a component higher in our scene graph needs to to know the position of each blob. Because the position is controlled by useSpring you can't simple submit springs.position to the store because you'll be dispatching the whole SpringValue object, which is unnecessary and can weigh down your external store.

Instead, you can use the onChange event handler to get the value of your springs and react to them accordingly. The code below is a conveluted example but demonstrates how you could use the onChange event handler to sync a THREE.Vector2 that is then returned when the parent component requires it via useImperativeHandle.

import { useRef, useEffect, useCallback, forwardRef, useState, useImperativeHandle, } from 'react'
import { useSpring, animated } from '@react-spring/three'
import { Canvas, useThree } from '@react-three/fiber'
import { MeshDistortMaterial } from '@react-three/drei'
import { Vector2 } from 'three'
const AnimatedMeshDistortMaterial = animated(MeshDistortMaterial)
const MyScene = forwardRef(({}, ref) => {
const isOver = useRef(false)
const [vector2] = useState(() => new Vector2())
const { width, height } = useThree(state => state.size)
const [springs, api] = useSpring(
() => ({
scale: 1,
position: [0, 0],
color: '#ff6d6d',
onChange: ({ value }) => {
vector2.set(value.position[0], value.position[1])
},
config: key => {
switch (key) {
case 'scale':
return {
mass: 4,
friction: 10,
}
case 'position':
return { mass: 4, friction: 220 }
default:
return {}
}
},
}),
[]
)
useImperativeHandle(ref, () => ({
getCurrentPosition: () => vector2,
}))
const handleClick = useCallback(() => {
let clicked = false
return () => {
clicked = !clicked
api.start({
color: clicked ? '#569AFF' : '#ff6d6d',
})
}
}, [])
const handlePointerEnter = () => {
api.start({
scale: 1.5,
})
}
const handlePointerLeave = () => {
api.start({
scale: 1,
})
}
const handleWindowPointerOver = useCallback(() => {
isOver.current = true
}, [])
const handleWindowPointerOut = useCallback(() => {
isOver.current = false
api.start({
position: [0, 0],
})
}, [])
const handlePointerMove = useCallback(
e => {
if (isOver.current) {
const x = (e.offsetX / width) * 2 - 1
const y = (e.offsetY / height) * -2 + 1
api.start({
position: [x * 5, y * 2],
})
}
},
[api, width, height]
)
useEffect(() => {
window.addEventListener('pointerover', handleWindowPointerOver)
window.addEventListener('pointerout', handleWindowPointerOut)
window.addEventListener('pointermove', handlePointerMove)
return () => {
window.removeEventListener('pointerover', handleWindowPointerOver)
window.removeEventListener('pointerout', handleWindowPointerOut)
window.removeEventListener('pointermove', handlePointerMove)
}
}, [handleWindowPointerOver, handleWindowPointerOut, handlePointerMove])
return (
<animated.mesh onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} onClick={handleClick()} scale={springs.scale} position={springs.position.to((x, y) => [x, y, 0])}>
<sphereGeometry args={[1.5, 64, 32]} />
<AnimatedMeshDistortMaterial speed={5} distort={0.5} color={springs.color} />
</animated.mesh>
)
})
export default function MyComponent() {
const blobApi = useRef(null)
useEffect(() => {
const interval = setInterval(() => {
if (blobApi.current) {
const { x, y } = blobApi.current.getCurrentPosition()
console.log('the blob is at position', { x, y })
}
}, 2000)
return () => clearInterval(interval)
}, [])
return (
<Canvas>
<ambientLight intensity={0.8} />
<pointLight intensity={1} position={[0, 6, 0]} />
<MyScene ref={blobApi} />
</Canvas>
)
}

Troubleshooting

Experiencing Jank?

Whilst jank in react-three-fiber cannot be purely blamed on react-spring you might find toward the end of an animation that there's a subtle jump, which is visible in this demo. It's not pretty, is it?

Whilst by default it would be nice to have this issue resolved without you having to interact and this is something we'll consider for the next breaking change in the meantime what you can use is the precision config prop to avoid this.

By setting the prop to a value like 0.0001 you can notice there is no jump towards 0. This is because the precision prop is used to figure out how close the animated value can get to the end goal before we consider the animated value to be equal to the end goal.