Background
react-spring
does not rely on timing, like the css transition api, but instead does transitions and animations from a physics context. To achieve this with acceptable performance in React, it bypasses React and makes modifications to the relevant DOM nodes themselves.
Regular react-spring
animation components
As you might have seen, all regular DOM nodes exist as react-spring
equivalents. For example animation.span
, animation.div
etc... These wrap the native DOM elements with the necessary functionality for react-spring
to work. What is worth to notice here are these two subtleties:
- The functionality of
react-spring
is attached to a single DOM node
- Because the functionality is attached to a native DOM node, only native DOM element props are used
Both of these facts have implications for how we can use custom components wrapped in animated
.
Our custom component
Let's work with a simple scenario using React functional components and Typescript and see how you can translate it into a custom react-spring
component.
Let's say you have a div
whose background color you want to animate when transitioning from one color to another after clicking it.
1. Without react-spring
The basic approach would be:
const Comp: FC = () => {
const [color, setColor] = useState<string>("green")
return (
<div
style={{
backgroundColor: color,
transition: "background-color 1s"
}}
onClick={ () => setColor(color => color === "blue" ? "green" : "blue") }
/>
)
}
2. With react-spring
basic usage
Doing the same thing with react-spring
basic usage of useSpring
would result in
const Comp: FC = () => {
const [color, setColor] = useState<string>("green")
const springColor = useSpring({ backgroundColor: color})
return (
<animated.div
style={springColor}
onClick={ () => setColor(color => color === "blue" ? "green" : "blue") }
/>
)
}
3. With react-spring
best practices usage
Even better is to use the api functions so that we don't have to rerender the component on every color change. To be clear, when you use this method, you're not changing any of the props passed to the component you want to animate, and so you can change its state via the api without rerendering it, as long as Comp
itself doesn't rerender.
const Comp: FC = () => {
const [springColor, api] = useSpring(() => ({ backgroundColor: "green" }))
return (
<animated.div
style={springColor}
onClick={ () => api.start({ backgroundColor: springColor.backgroundColor.goal === "blue" ? "green" : "blue" })}
/>
)
}
4. With delegation to class
Let's think about it. You're passing some wrapped property to these animated
components. These properties are of type SpringValue<T>
and they can be instantiated by means of new
or via, for example, useSpring
. Our first step towards building a custom component would be to simply pass these as properties to a component that has an animated
component in it:
export interface CompProps {
color: SpringValue<string>;
onChangeColor: () => void;
}
const Comp: FC<CompProps> = (props: CompProps) => {
return (
<animated.div
style={{ backgroundColor: props.color }}
onClick={props.onChangeColor}
/>
)
}
const Parent: FC = () => {
const [springColor, api] = useSpring(() => ({ backgroundColor: "green" }));
return (
<Comp
color={springColor.backgroundColor}
onChangeColor={() => api.start({
backgroundColor: springColor.backgroundColor.goal === "blue" ? "green" : "blue"
})}
/>
)
}
5. With a naive custom component wrapped in animated
Now we are ready to do the replacement and wrap our property in animated
instead of using the animated
native element inside our component.
export interface CompProps {
style: CSSProperties;
onChangeColor: () => void;
}
const Comp: FC<CompProps> = (props: CompProps) => {
return (
<div
style={props.style}
onClick={props.onChangeColor}
/>
)
}
const WrappedComp: AnimatedComponent<FC<CompProps>> =
animated(Comp)
const Parent: FC = () => {
const [springColor, api] = useSpring(() => ({ backgroundColor: "green" }));
return (
<WrappedComp
style={{ backgroundColor: springColor.backgroundColor }}
onChangeColor={() =>
api.start({
backgroundColor:
springColor.backgroundColor.goal === "blue" ? "green" : "blue"
})
}
/>
)
}
Note well that now our wrapped component looks like a regular component and it shows no sign of being used together with react-spring
. Nonetheless, as we shall see, there are still some additional requirements on it for the integration with react-spring
to work as intended. Note how we are no longer suppling backgroundColor
as a prop but instead we use style
. Also, all the animated props passed to our custom component are props available on the native div
element on which our custom component attaches the forwarded ref. More on this further down.
The above component is naive, because the animated
wrapper can't update the wrapped component without rerendering. Why? Simply because our wrapped component does not accept a ref and so demanding from react-spring
to be able to update our component without rerendering it is unreasonable.
6. With a proper custom component wrapped in animated
Let's enhance our wrapped component by letting it accept a ref.
const Comp: FC<CompProps & RefAttributes<HTMLDivElement>> =
forwardRef<HTMLDivElement,CompProps>(
(props, ref) => {
return (
<div
ref={ref}
style={props.style}
onClick={props.onChangeColor}
/>
)
}
)
Now react-spring
will sense that our wrapped component accepts a ref and so it will refrain from rerendering it on every change and instead use the ref to update it. Because updates take place via a ref, it is important that the props are actual props on the element we want to change; if React is needed to map the custom props to the actual props on the DOM element, then we are required to rerender on every new animation value, which is suboptimal. Nonetheless, this is exactly what happens when we DON'T enable our custom component to take a ref. Therefore, version 5 would have worked even if we had continued to use the custom props backgroundColor
instead of style
. Version number 6 had however not worked, and the property backgroundColor
, would simply have been added to the DOM element on which the ref was set, which would not result in any change since this property is not a property on the native div
DOM element.
Sandboxes
I have made two sandboxes available:
The first sandbox shows the components in this answer. Each component makes a printout in the console whenever it renders. Check these printouts and verify the behaviours described above. Sandbox
The second sandbox is on the same theme but slightly more advanced. Here the number of renderings is kept up to date, so that we can verify different behaviours. One of the take-aways from this sandbox is that all the animated changes that we do using the api are "free" in the sense that it does not increase the number of renderings for the affected components. When the parent rerenders, as usual, all children rerender as well. A list of important points has been added at the bottom. Sandbox
Conclusion
- Always use the api methods when using
react-spring
hooks.
- Always make your components accept a ref when wrapping them in
animated
.
- If you want to be able to update your wrapped component without rerendering it, you can only attach the behaviour of
react-spring
to ONE element. react-spring
does its updates using a ref and since you can't attach a ref to multiple elements at the same time (nor does forwardRef
provide a way to add multiple refs), you can't use your springs in multiple places in a wrapped component without rerendering it or you will not arrive at the expected functionality. With such a component, it is better to pass SpringValue<T>
as props and use animated
native elements inside the component instead.
Don't:
const NestedComp = ({ style1, style2 }) => {
...
return (
<div style={ style1 }>
<div style={ style2 }>
....
</div>
</div>
)
}
const Wrapped = animated(NestedComp)
Do:
const NestedComp = ({ springStyle1, springStyle2 }) => {
...
return (
<animated.div style={ springStyle1 }>
<animated.div style={ springStyle2 }>
....
</animated.div>
</animated.div>
)
}
- Make sure that the custom props you use in animations on your element is compatible with the props on the native DOM element on which you attach the ref, otherwise
react-spring
can't properly update the element directly (instead it just sets the property which, if it doesn't exist on the DOM element, has no effect).
Don't:
const Comp = ({ color }) => {
...
return (
<div style={{ backgroundColor: props.color }} />
)
}
const WrappedComp = animated(Comp)
const Parent = () => {
...
const [springProp, api] ) = useSpring(() => ({ color: "green" }))
...
return <WrappedComp color={ springProp.color } />
}
Do:
const Comp = ({ style }) => {
...
return (
<div style={props.style} />
)
}
const WrappedComp = animated(Comp)
const Parent = () => {
...
const [springProp, api] ) = useSpring(() => ({ color: "green" }))
...
return (
<WrappedComp
style={{ backgroundColor: springProp.color }}
/>
)
}
In the above, we want to update Comp
without using React, but React is required to assemble the proper style
prop since color
is not a native prop of a div
element, therefore updating color
via a ref will only lead to the color
prop being set on the div
element which does nothing. On the other hand, when we add the style
property already in the parent, the animated
component will detect that one of the style
props is a SpringValue
and updating it accordingly will have the expected effect.
Finally, remember that if we are not after updating our custom component with the api, we could simply refrain from designing our custom component so that it can take a ref and use whichever prop names we want; react-spring
will anyways now rerender the component on every animation frame and so React will map all the custom props to the correct native DOM element props. Nonetheless, this strategy of execution is hopefully not desirable.