Making Sense of react-spring

Avatar of Adam Rackis
Adam Rackis on (Updated on )

Animation is one of the trickier things to get right with React. In this post, I’ll try to provide the introduction to react-spring I wish I had when I first started out, then dive into some interesting use cases. While react-spring isn’t the only animation library for React, it’s one of the more popular (and better) ones.

I’ll be using the newest version 9 which, as of this writing, is in release candidate status. If it’s not fully released by the time you read this, be sure to install it with react-spring@next. From on what I’ve seen and from what the lead maintainer has told me, the code is incredibly stable. The only issue I’ve seen is a slight bug when used with concurrent mode, which can be tracked in the GitHub repo.

react-spring redux

Before we get to some interesting application use cases, let’s take a whirlwind intro. We’ll cover springs, height animation, and then transitions. I’ll have a working demo at the end of this section, so don’t worry if things get a little confusing along the way. 

springs

Let’s consider the canonical “Hello world” of animation: fading content in and out. Let’s stop for a moment and consider how we’d switch opacity on and off without any kind of animation. It’d look something like this:

export default function App() {
  const [showing, setShowing] = useState(false);
  return (
    <div>
      <div style={{ opacity: showing ? 1 : 0 }}>
        This content will fade in and fade out
      </div>
      <button onClick={() => setShowing(val => !val)}>Toggle</button>
      <hr />
    </div>
  );
}

Easy, but boring. How do we animate the changing opacity? Wouldn’t it be nice if we could declaratively set the opacity we want based on state, like we do above, except have those values animate smoothly? That’s what react-spring does. Think of react-spring as our middle-man that launders our changing style values, so it can produce the smooth transition between animating values we want. Like this:

const [showA, setShowA] = useState(false);


const fadeStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0 },
  to: {
    opacity: showA ? 1 : 0
  }
});

We specify our initial style values with from, and we specify the current value in the to section, based on our current state. The return value, fadeStyles, contains the actual style values we apply to our content. There’s just one last thing we need…

You might think you could just do this:

<div style={fadeStyles}>

…and be done. But instead of using a regular div, we need to use a react-spring div that’s created from the animated export. That may sound confusing, but all it really means is this:

<animated.div style={fadeStyles}>

And that’s it. 

Animating height 

Depending on what we’re animating, we may want the content to slide up and down, from a zero height to its full size, so the surrounding contents adjust and flow smoothly into place. You might wish that we could just copy the above, with height going from zero to auto, but alas, you can’t animate to auto height. That neither works with vanilla CSS, nor with react-spring. Instead, we need to know the actual height of our contents, and specify that in the to section of our spring.

We need to have the height of any arbitrary content on the fly so we can pass that value to react-spring. It turns out the web platform has something specifically designed for that: a ResizeObserver. And the support is actually pretty good! Since we’re using React, we’ll of course wrap that usage in a hook. Here’s what mine looks like:

export function useHeight({ on = true /* no value means on */ } = {} as any) {
  const ref = useRef<any>();
  const [height, set] = useState(0);
  const heightRef = useRef(height);
  const [ro] = useState(
    () =>
      new ResizeObserver(packet => {
        if (ref.current && heightRef.current !== ref.current.offsetHeight) {
          heightRef.current = ref.current.offsetHeight;
          set(ref.current.offsetHeight);
        }
      })
  );
  useLayoutEffect(() => {
    if (on && ref.current) {
      set(ref.current.offsetHeight);
      ro.observe(ref.current, {});
    }
    return () => ro.disconnect();
  }, [on, ref.current]);
  return [ref, height as any];
}

We can optionally provide an on value that switches measuring on and off (which will come in handy later). When on is true, we tell our ResizeObserver to observe out content. We return a ref that needs to be applied to whatever content we want measured, as well as the current height.

Let’s see it in action.

const [heightRef, height] = useHeight();
const slideInStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0, height: 0 },
  to: {
    opacity: showB ? 1 : 0,
    height: showB ? height : 0
  }
});

useHeight gives us a ref and a height value of the content we’re measuring, which we pass along to our spring. Then we apply the ref and apply the height styles.

<animated.div style={{ ...slideInStyles, overflow: "hidden" }}>
  <div ref={heightRef}>
    This content will fade in and fade out with sliding
  </div>
</animated.div>

Oh, and don’t forget to add overflow: hidden to the container. That allows us to properly container our adjusting height values.

Animating transitions

Lastly, let’s look at adding and removing animating items to and from the DOM. We already know how to animate changing values of an item that exists and is staying in the DOM, but to animate the adding, or removing of items, we need a new hook: useTransition.

If you’ve used react-spring before, this is one of the few places where version 9 has some big changes to its API. Let’s take a look.

In order to animate a list of items, like this:

const [list, setList] = useState([]);

…we’ll declare our transition function like this:

const listTransitions = useTransition(list, {
  config: config.gentle,
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: { opacity: 1, transform: "translate3d(0%, 0px, 0px)" },
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" },
  keys: list.map((item, index) => index)
});

As I alluded earlier, the return value, listTransitions, is a function. react-spring is tracking the list array, keeping tabs on the items which are added and removed. We call the listTransitions function, provide a callback accepting a single styles object and a single item, and react-spring will call it for each item in the list with the right styles, based on whether it’s newly added, newly removed, or just sitting in the list.

Note the keys section: This allows us to tell react-spring how to identify objects in the list. In this case, I decided to tell react-spring that an item’s index in the array uniquely defines that item. Normally this would be a terrible idea, but for now, it lets us see the feature in action. In the demo below, the “Add item” button adds an item to the end of the list when clicked, and the “Remove last item” button removes the most recently added item from the list. So, if you type in the input box then quickly hit the add button then the remove button, you’ll see the same item smoothly begin to enter, and then immediately, from whatever stage in the animation it’s at, begin to leave. Conversely, if you add an item, then quickly hit the remove button and the add button, the same item will begin to slide off, then abruptly stop in place, and slide right back to where it was.

Here’s that demo

Whew, that was a ton of words! Here’s a working demo, showing everything we just covered in action.

Odds and Ends

Did you notice how, when you slide down the content in the demo, it sort of bounces into place, like… a spring? That’s where the name comes from: react-spring interpolates our changing values using spring physics. It doesn’t simply chop the value changing into N equal deltas that it applies over N equal delays. Instead, it uses a more sophisticated algorithm that produces that spring-like effect, which will appear more natural.

The spring algorithm is fully configurable, and it comes with a number of presets you can take off the shelf — the demo above uses the stiff, and gentle presets. See the docs for more info.

Note also how I’m animating values inside of translate3d values. As you can see, the syntax isn’t the most terse, and so react-spring provides some shortcuts. There’s documentation on this, but for the remainder of this post I’ll continue to use the full, non-shortcut syntax for the sake of keeping things as clear as possible. 

I’ll close this section by calling attention to the fact that, when you slide the content up in the demo above, you’ll likely see the content underneath it get a little jumpy at the very end. This is a result of that same bouncing effect. It looks sharp when the content bounces down and into position, but less so when we’re sliding the content up. Stay tuned to see how we can switch it off. (Spoiler, it’s the clamp property).

A few things to consider with these sandboxes

Code Sandbox uses hot reloading. As you change the code, changes are usually reflected immediately. This is cool, but can wreck havoc on animations. If you start tinkering, and then see weird, ostensibly incorrect behavior, try refreshing the sandbox.

The other sandboxes in this post will make use of a modal. For reasons I haven’t quite been able to figure out, when the modal is open, you won’t be able to modify any code — the modal refuses to give up focus. So, be sure to close the modal before attempting any changes. 

Now let’s build something real

Those are the basic building blocks of react-spring. Let’s use them to build something more interesting. You might think, given everything above, that react-spring is pretty simple to use. Unfortunately, in practice, it can be tricky to figure out some subtle things you need to get right. The rest of this post will dive into many of these details. 

Prior blog posts I’ve written have been in some way related to my booklist side project. This one will be no different — it’s not an obsession, it’s just that that project happens to have a publicly-available GraphQL endpoint, and plenty of existing code that be can leveraged, making it an obvious target.

Let’s build a UI that allows you to open a modal and search for books. When the results come in, you can add them to a running list of selected books that display beneath the modal. When you’re done, you can close the modal and click a button to find books similar to the selection.

We’ll start with a functioning UI then animate the pieces step by step, including interactive demos along the way.

If you’re really eager to see what the final result will look like, or you’re already familiar with react-spring and want to see if I’m covering anything you don’t already know, here it is (it won’t win any design awards, I’m well aware). The rest of this post will cover the journey getting to that end state, step by step.

Animating our modal

Let’s start with our modal. Before we start adding any kind of data, let’s get our modal animating nicely.  Here’s what a basic, un-animated modal looks like. I’m using Ryan Florence’s Reach UI (specifically the modal component), but the idea will be the same no matter what you use to build your modal. We’d like to get our backdrop to fade in, and also transition our modal content.

Since a modal is conditionally rendered based on some sort of “open” property, we’ll use the useTransition hook. I was already wrapping the Reach UI modal with my own modal component, and rendering either nothing, or the actual modal based on the isOpen property. We just need to go through the transition hook to get it animating.

Here’s what the transition hook looks like:

const modalTransition = useTransition(!!isOpen, {
  config: isOpen ? { ...config.stiff } : { duration: 150 },
  from: { opacity: 0, transform: `translate3d(0px, -10px, 0px)` },
  enter: { opacity: 1, transform: `translate3d(0px, 0px, 0px)` },
  leave: { opacity: 0, transform: `translate3d(0px, 10px, 0px)` }
});

There’s not too many surprises here. We want to fade things in and provide a slight vertical transition based on whether the modal is active or not. The odd piece is this:

config: isOpen ? { ...config.stiff } : { duration: 150 },

I want to only use spring physics if the modal is opening. The reason for this — at least in my experience — is when you close the modal, the backdrop takes too long to completely vanish, which leaves the underlying UI un-interactive for too long. So, when the modal opens, it’ll nicely bounce into place with spring physics, and when closed, it’ll quickly vanish in 150ms.

And, of course, we’ll render our content via the transition function our hook returns. Notice that I’m plucking the opacity style off of the styles object to apply to the backdrop, and then applying all the animating styles to the actual modal content.

return modalTransition(
  (styles, isOpen) =>
    isOpen && (
      <AnimatedDialogOverlay
        allowPinchZoom={true}
        initialFocusRef={focusRef}
        onDismiss={onHide}
        isOpen={isOpen}
        style={{ opacity: styles.opacity }}
      >
      <AnimatedDialogContent
        style={{
          border: "4px solid hsla(0, 0%, 0%, 0.5)",
          borderRadius: 10,
          maxWidth: "400px",
          ...styles
        }}
      >
        <div>
          <div>
            <StandardModalHeader caption={headerCaption} onHide={onHide} />
            {children}
          </div>
        </div>
      </AnimatedDialogContent>
    </AnimatedDialogOverlay>
  )
);

Base setup

Let’s start with the use case I described above. If you’re following along with the demos, here’s a full demo of everything working, but with zero animation. Open the modal, and search for anything (feel free to just hit Enter in the empty textbox). You should hit my GraphQL endpoint, and get back search results from my own personal library.

The rest of this post will focus on adding animations to the UI, which will give us a chance to see a before and after, and (hopefully) observe how much nicer some subtle, well-placed animations can make a UI. 

Animating the modal size

Let’s start with the modal itself. Open it and search for, say, “jefferson.” Notice how the modal abruptly becomes larger to accommodate the new content. Can we have the modal animate to larger (and smaller) sizes? Of course. Let’s dig out our trusty useHeight hook, and see what we can do.

Unfortunately, we can’t simply slap the height ref on a wrapper in our content, and then stick the height in a spring. If we did this, we’d see the modal slide into its initial size. We don’t want that; we want our fully formed modal to appear in the right size, and re-size from there.

What we want to do is wait for our modal content to be rendered in the DOM, then set our height ref, and switch on our useHeight hook, to start measuring. Oh, and we want our initial height to be set immediately, and not animate into place. It sounds like a lot, but it’s not as bad as it sounds.

Let’s start with this:

const [heightOn, setHeightOn] = useState(false);
const [sizingRef, contentHeight] = useHeight({ on: heightOn });
const uiReady = useRef(false);

We have some state for whether we’re measuring our modal’s height. This will be set to true when the modal is in the DOM. Then we call our useHeight hook with the on property, for whether we’re active. Lastly, some state to hold whether our UI is ready, and we can begin animating.

First things fist: how do we even know when our modal is actually rendered in the DOM? It turns out we can use a ref that lets us know. We’re used to doing <div ref={someRef} in React, but you can actually pass a function, which React will call with the DOM node after it’s rendered. Let’s define that function now.

const activateRef = ref => {
  sizingRef.current = ref;
  if (!heightOn) {
    setHeightOn(true);
  }
};

That sets our height ref and switches on our useHeight hook. We’re almost done!

Now how do we get that initial animation to not be immediate? The useSpring hook has two new properties we’ll look at now. It has an immediate property which tells it to make states changes immediate instead of animating them. It also has an onRest callback which fires when a state change finishes.

Let’s leverage both of them. Here’s what the final hook looks like:

const heightStyles = useSpring({
  immediate: !uiReady.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

Once any height change is completed, we set the uiReady ref to true. So long as it’s false, we tell react-spring to make immediate changes. So, when our modal first mounts, contentHeight is zero (useHeight will return zero if there’s nothing to measure) and the spring is just chilling, doing nothing. When the modal switches to open and actual content is rendered, our activateRef ref is called, our useHeight will switch on, we’ll get an actual height value for our contents, our spring will set it “immediately,” and, finally, the onRest callback will trigger, and future changes will be animated. Phew!

I should point out that if, in some alternate use case we did immediately have a correct height upon the first render, we’d be able to simplify the above hook to just this:

const heightStyles = useSpring({
  to: {
    height: contentHeight
  },
  config: config.stiff,
})

…which can actually be further simplified to this:

const heightStyles = useSpring({
  height: contentHeight,
  config: config.stiff,
})

Our hook would render initially with the correct height, and any changes to that value would be animated. But since our modal renders before it’s actually shown, we can’t avail ourselves to this simplification. 

Keen readers might wonder what happens when you close the modal. Well, the content will un-render and the height hook will just stick with the last reported height, though still “observing” a DOM node that’s no longer in the DOM. If you’re worried about that, feel free to clean things up better than I have here, perhaps with something like this:

useLayoutEffect(() => {
  if (!isOpen) {
    setHeightOn(false);
  }
}, [isOpen]);

That’ll cancel the ResizeObserver for that DOM node and fix the memory leak.

Animating the results

Next, let’s look at animating the changing of the results within the modal. If you run a few searches, you should see the results immediately swap in and out.

Take a look at the SearchBooksContent component in the searchBooks.js file. Right now, we have const booksObj = data?.allBooks; which plucks the appropriate result set off of the GraphQL response, and then later renders them.

{booksObj.Books.map(book => (
  <SearchResult
    key={book._id}
    book={book}
    selected={selectedBooksMap[book._id]}
    selectBook={selectBook}
    dispatch={props.dispatch}
  />
))}

As fresh results come back from our GraphQL endpoint, this object will change, so why not take advantage of that fact, and pass it to the useTransition hook from before, and get some transition animations defined.

const resultsTransition = useTransition(booksObj, {
  config: { ...config.default },
  from: {
    opacity: 0,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  enter: {
    opacity: 1,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  leave: {
    opacity: 0,
    position: "absolute",
    transform: "translate3d(90%, 0px, 0px)"
  }
});

Note the change from position: static to position: absolute. An outgoing result set with absolute positioning has no effect on its parent’s height, which is what we want. Our parent will size to the new contents and, of course, our modal will nicely animate to the new size based on the work we did above.

As before, we’ll use our transition function to render our content:

<div className="overlay-holder">
  {resultsTransition((styles, booksObj) =>
    booksObj?.Books?.length ? (
      <animated.div style={styles}>
        {booksObj.Books.map(book => (
          <SearchResult
            key={book._id}
            book={book}
            selected={selectedBooksMap[book._id]}
            selectBook={selectBook}
            dispatch={props.dispatch}
          />
        ))}
      </animated.div>
    ) : null
  )}

Now new result sets will fade in, while outgoing sets of results will fade (and slightly slide) out to give the user an extra cue that things have changed.

Of course, we also want to animate any messaging, such as when there’s no results, or when the user has selected everything in the result set. The code for that is pretty repetitive with everything else in here, and since this post is already getting long, I’ll leave the code in the demo.

Animating selected books (out)

Right now, selecting a book instantly and abruptly vanishes it from the list. Let’s apply our usual fade out while sliding it out to the right. And as the item is sliding out to the right (via transform), we probably want its height to animate to zero so the list can smoothly adjust to the exiting item, rather than have it slide out, leaving behind an the empty box, which then immediately disappears.

By now, you probably think this is easy. You’re expecting something like this:

const SearchResult = props => {
  let { book, selectBook, selected } = props;


  const initiallySelected = useRef(selected);
  const [sizingRef, currentHeight] = useHeight();


  const heightStyles = useSpring({
    config: { ...config.stiff, clamp: true },
    from: {
      opacity: initiallySelected.current ? 0 : 1,
      height: initiallySelected.current ? 0 : currentHeight,
      transform: "translate3d(0%, 0px, 0px)"
    },
    to: {
      opacity: selected ? 0 : 1,
      height: selected ? 0 : currentHeight,
      transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
    }
  }); 

This uses our trusted useHeight hook to measure our content, using the selected value to animate the item that’s leaving. We’re tracking the selected prop and animating to, or starting with, a height of 0 if it’s already selected, rather than simply removing the item and using a transition. This allows different result sets that have the same book to correctly decline to display it, if it’s selected.

This code does work. Give it a try in this demo.

But there’s a rub. If you select most of the books in a result set, there will be a kind of bouncy animation chain as you continue selecting. The book starts animating out of the list, and then the modal’s height itself starts trailing behind.

This looks goofy in my opinion, so let’s see what we can do about it. 

We’ve already seen how we can use the immediate property to turn off all spring animations. We’ve also seen the onRest callback fire when an animation finishes, and I’m sure you won’t be surprised to learn there’s an onStart callback which does what you’d expect. Let’s use those pieces to allow the content inside our modal to “turn off” the modal’s height animation when the content itself is animating heights.

First, we’ll add some state to our modal that switches animation on and off.

const animatModalSizing = useRef(true);
const modalSizingPacket = useMemo(() => {
  return {
    disable() {
      animatModalSizing.current = false;
    },
    enable() {
      animatModalSizing.current = true;
    }
  };
}, []);

Now, let’s tie it into our transition from before.

const heightStyles = useSpring({
  immediate: !uiReady.current || !animatModalSizing.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

Great. Now how do we get that modalSizingPacket down to our content, so whatever we’re rendering can actually switch off the modal’s animation, when needed? Context of course! Let’s create a piece of context.

export const ModalSizingContext = createContext(null);

Then, we’ll wrap all of our modal’s content with it:

<ModalSizingContext.Provider value={modalSizingPacket}>

Now our SearchResult component can grab it:

const { enable: enableModalSizing, disable: disableModalSizing } = useContext(
  ModalSizingContext
);

…and tie it right into its spring:

const heightStyles = useSpring({
  config: { ...config.stiff, clamp: true },
  from: {
    opacity: initiallySelected.current ? 0 : 1,
    height: initiallySelected.current ? 0 : currentHeight,
    transform: "translate3d(0%, 0px, 0px)"
  },
  to: {
    opacity: selected ? 0 : 1,
    height: selected ? 0 : currentHeight,
    transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
  },
  onStart() {
    if (uiReady.current) {
      disableModalSizing();
    }
  },
  onRest() {
    uiReady.current = true;
    setTimeout(() => {
      enableModalSizing();
    });
  }
});

Note the setTimeout at the very end. I’ve found it necessary to make sure the modal’s animation is truly shut off until everything’s settled.

I know that was a lot of code. If I moved too fast, be sure to check out the demo to see all this in action.

Animating selected books (in)

Let’s wrap this blog post up by animating the selected books that appear on the main screen, beneath the modal. Let’s have newly selected books fade in while sliding in from the left when selected, then slide out to the right while it’s height shrinks to zero when removed.

We’ll use a transition, but there already seems to be a problem because we need to account for each of the selected books needs to have its own, individual height. Previously, when we reached for useTransition, we’ve had a single from and to object that was applied to entering and exiting items.

Here, we’ll use an alternate form instead, allowing us to provide a function for the to object. It’s invoked with the actual animating item — a book object in this case — and we return the to object that contains the animating values. Additionally, we’ll keep track of a simple lookup object which maps each book’s ID to its height, and then tie that into our transition.

First, let’s create our map of height values:

const [displaySizes, setDisplaySizes] = useState({});
const setDisplaySize = useCallback(
  (_id, height) => {
    setDisplaySizes(displaySizes => ({ ...displaySizes, [_id]: height }));
  },
  [setDisplaySizes]
);

We’ll pass the setDisplaySizes update function to the SelectedBook component, and use it with useHeight to report back the actual height of each book.

const SelectedBook = props => {
  let { book, removeBook, styles, setDisplaySize } = props;
  const [ref, height] = useHeight();
  useLayoutEffect(() => {
    height && setDisplaySize(book._id, height);
  }, [height]);

Note how we check that the height value has been updated with an actual value before calling it. That’s so we don’t prematurely set the value to zero before setting the correct height, which would cause our content to animate down, rather than sliding in fully-formed. Instead, no height will initially be set, so our content will default to height: auto. When our hook fires, the actual height will set. When an item is removed, the height will animate down to zero, as it fades and slides out.

Here’s the transition hook:

const selectedBookTransitions = useTransition(selectedBooks, {
  config: book => ({
    ...config.stiff,
    clamp: !selectedBooksMap[book._id]
  }),
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: book => ({
    opacity: 1,
    height: displaySizes[book._id],
    transform: "translate3d(0%, 0px, 0px)"
  }),
  update: book => ({ height: displaySizes[book._id] }),
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" }
});

Notice the update callback. It will adjust our content if any of the heights change. (You can force this in the demo by resizing the results pane after selecting a bunch of books.)

For a little icing on top of our cake, note how we’re conditionally setting the clamp property of our hook’s config. As content is animating in, we have clamp off, which produces a nice (at least in my opinion) bouncing effect. But when leaving, it animates down, but stays gone, without any of the jitteriness we saw before with clamping turned off.

Bonus: Simplifying the modal height animation while fixing a bug in the process

After finishing this post, I found a bug in the modal implementation where, if the modal height changes while it’s not shown, you’ll see the old, now incorrect height the next time you open the modal, followed by the modal animating to the correct height. To see what I mean, have a look at this update to the demo. You’ll notice new buttons to clear, or force results into the modal when it’s not visible. Open the modal, then close it, click the button to add results, and re-open it — you should see it awkwardly animate to the new, correct height.

Fixing this also allows us to simplify the code for the height animation from before. The problem is that our modal currently continues to render in the React component tree, even when not shown. The height hook is still “running,” only to be updated the next time the modal is shown, rendering the children. What if we moved the modal’s children to its own, dedicated component, and brought the height hook with it? That way, the hook and animation spring will only be render when the modal is shown, and can start with correct values. It’s less complicated than it seems. Right now our modal component has this:

<animated.div style={{ overflow: "hidden", ...heightStyles }}>
  <div style={{ padding: "10px" }} ref={activateRef}>
    <StandardModalHeader
      caption={headerCaption}
      onHide={onHide}
    />
    {children}
  </div>
</animated.div>

Let’s make a new component that renders this markup, including the needed hooks and refs:

const ModalContents = ({ header, contents, onHide, animatModalSizing }) => {
  const [sizingRef, contentHeight] = useHeight();
  const uiReady = useRef(false);

  const heightStyles = useSpring({
    immediate: !uiReady.current || !animatModalSizing.current,
    config: { ...config.stiff },
    from: { height: 0 },
    to: { height: contentHeight },
    onRest: () => (uiReady.current = true)
  });

  return (
    <animated.div style={{ overflow: "hidden", ...heightStyles }}>
      <div style={{ padding: "10px" }} ref={sizingRef}>
        <StandardModalHeader caption={header} onHide={onHide} />
        {contents}
      </div>
    </animated.div>
  );
};

This is a significant reduction in complexity compared to what we had before. We no longer have that activateRef function, and we no longer have the heightOn state that was set in activateRef. This component is only rendered by the modal if it’s being shown, which means we’re guaranteed to have content, so we can just add a regular ref to our div. Unfortunately, we do still need our uiReady state, since even now we don’t initially have our height on first render; that’s not available until the useHeight layout effect fires immediately after the first render finishes.

And, of course, this solves the bug from before. No matter what happens when the modal is closed, when it re-opens, this component will render anew, and our spring will start with a fresh value for uiReady.

Parting thoughts

If you’ve stuck with me all this way, thank you! I know this post was long, but I hope you found some value in it.

react-spring is an incredible tool for creating robust animations with React. It can be low-level at times, which can make it hard to figure out for non-trivial use cases. But it’s this low-level nature that makes it so flexible.