React-spring: onRest callback within useSpring cannot access updated component state

Created on 4 May 2019  路  7Comments  路  Source: pmndrs/react-spring

This isn't really a bug per-se since it's kinda how closures work, but let's say you have a useSpring like this:

const [isOpen, setIsOpen] = React.useState(false);

 const [{ xy }, setSpring] = useSpring(() => {
    const { x, y } = getDefaultPositions(isOpen, position);
    return {
      xy: [x, y],
      config: animationConfig,
      onRest: ya => {
        console.log(isOpen);
      }
    };
  });

From my experience, isOpen will always return false even if its value has changed to true within the component. Ideally we could access updated state here.

You can see this in action on this codesandbox. Open the console and try dragging the dot around.

In my experience, you can fix this sort of thing by doing something like this:

function useSpring(fns) {
  const callbacks = React.useRef(fns);

 React.useEffect(() => {
   callbacks.current = fns;
 }, [fns])

 // and then make calls to callback.current.onRest... 
}

I've taken a quick look at the source, but feel a bit like a deer in headlights. Not sure how easy this would be to fix for someone more knowledgeable of the codebase.

bug

Most helpful comment

I've found a solution on my end. Refs to the rescue!

const [isOpen, setIsOpen] = React.useState(false);
const isOpenRef = React.useRef(isOpen);

React.useEffect(() => {
  isOpenRef.current = isOpen;
}, [isOpen])

 const [{ xy }, setSpring] = useSpring(() => {
    const { x, y } = getDefaultPositions(isOpen, position);
    return {
      xy: [x, y],
      config: animationConfig,
      onRest: ya => {
        console.log(isOpenRef.current);
      }
    };
  });

I still wonder if this could be made easier.

All 7 comments

I've found a solution on my end. Refs to the rescue!

const [isOpen, setIsOpen] = React.useState(false);
const isOpenRef = React.useRef(isOpen);

React.useEffect(() => {
  isOpenRef.current = isOpen;
}, [isOpen])

 const [{ xy }, setSpring] = useSpring(() => {
    const { x, y } = getDefaultPositions(isOpen, position);
    return {
      xy: [x, y],
      config: animationConfig,
      onRest: ya => {
        console.log(isOpenRef.current);
      }
    };
  });

I still wonder if this could be made easier.

useCallback can be useful here

const [isOpen, setIsOpen] = React.useState(false)
const onRest = React.useCallback(() => {
  console.log('isOpen:', isOpen)
}, [isOpen])

const props = useSpring(() => {
  return {
    onRest: () => onRest(),
  }
})

Closing since this isn't an issue with react-spring. If you have an idea to make this easier, please open a new issue. Thanks! 馃憤

@satya164 recently pointed out here that my solution is incorrect. (Thanks Satya!)

Here's another workaround that should work as expected:

const [isOpen, setIsOpen] = React.useState(false)
const [props, update] = useSpring(() => ({ ... }))
React.useEffect(() => {
  update({
    onRest() {
      console.log('isOpen:', isOpen)
    }
  })
}, [isOpen])

Once v9 is released, you'll be able to use a "dependencies array" just like useEffect:

const [isOpen, setIsOpen] = React.useState(false)
const [props, update] = useSpring(() => ({
  /* ...initialProps */
  onRest() {
    console.log('isOpen:', isOpen)
  }
}), [isOpen])

Note: If your initial props are expensive to compute (which they usually are if you're passing a function to useSpring), you'll want to wrap them in a useMemo call like this:

const [isOpen, setIsOpen] = React.useState(false)
const initialProps = React.useMemo(() => ({ ... }), [])
const [props, update] = useSpring(() => ({
  ...initialProps,
  onRest() {
    console.log('isOpen:', isOpen)
  }
}), [isOpen])

Once again, any ideas to make this easier are welcome in this thread.

Most of the time, it's premature optimization to pass a function to useSpring. For your own sanity, just pass an object.

const [isOpen, setIsOpen] = React.useState(false)
const [props, update] = useSpring({
  ...initialProps,
  onRest() {
    console.log('isOpen:', isOpen)
  }
}, [isOpen])

Note: This is the v9 API that isn't released yet (not even in the beta). See here: #670

Looking forward to the new API - looks great. fwiw, i've consistently been using my ref solution that I posted above, which seems to work.

Most of the time, it's premature optimization to pass a function to useSpring. For your own sanity, just pass an object.

const [isOpen, setIsOpen] = React.useState(false)
const [props, update] = useSpring({
  ...initialProps,
  onRest() {
    console.log('isOpen:', isOpen)
  }
}, [isOpen])

Note: This is the v9 API that isn't released yet (not even in the beta). See here: #670

Passing a function rather than an object allows to directly apply the animation on the component, thus avoiding a useless re-render of it and all its children. So it's a costless optimization which could make a big difference (as if the component you are animating does contain a large tree).

The best solution I have found is to put onRest in the setter function. Like this :

const [ isOpen, setIsOpen ] = useState(false);
const [ spring, setSpring ] = useSpring(() => ({
  config: { ... }
}));

setSpring({ ...props, onRest() { console.log(isOpen) } })

From my code :

const [ taskEditMode, setTaskEditMode ] = useState(false);
const [ taskPannelIsOpen, setTaskPannelIsOpen ] = useState(false);
const [ taskEditSpring, setTaskEditSpring ] = useSpring(() => ({
  config: { duration: 1000 }
}));

setTaskEditSpring({ height: taskEditMode ? 5 * yUnitLength : yUnitLength, onRest: () => {
  setTaskPannelIsOpen(taskEditMode) }
});
Was this page helpful?
0 / 5 - 0 ratings