Ohans Emmanuel Author, Understanding Redux. I Love God. I Love GF a little too much 💕🤣 http://thereduxjsbooks.com

How is getSnapshotBeforeUpdate implemented with Hooks?

6 min read 1829

TL;DR: The short answer is getSnapshotBeforeUpdate can’t be implemented with Hooks. However, the more interesting question is why not? And what can we learn from implementing this ourselves?

Introduction

It’s been over a year since the introduction of Hooks and it’s no surprise they have been widely adopted by the React community. The introduction of React Hooks inspired other libraries such as Vue, to also create a function-based component API . One year later, it is fair to say the frontend community has largely embraced the functional approach to building components promoted by Hooks.

For the curious mind, you must have at some point asked if Hooks cover all use cases React classes handled. Well, the answer is no. There are no Hook equivalents for the getSnapshotBeforeUpdate and componentDidCatch class lifecycle methods. To be fair, these methods aren’t used as much as the others – regardless they are still helpful lifecycle methods and the React team had promised to release this “soon”.

With that being said, could we at least try to implement the getSnapshotBeforeUpdate lifecycle method with Hooks? If it were possible within the confines of the Hooks available to us now, what would be our best shot at implementing this?

In the following section, we’ll try to implement the getSnapshotBeforeUpdate using useLayoutEffect and useEffect.

The demo app

To make this as pragmatic as possible we’ll work with the following demo app:

example app

This app has a pretty simple setup. The app renders a football and scored points on the left, but more importantly, it also renders a chat pane to the right. What’s important about this chat pane is that as more chat messages are rendered in the pane (by clicking the add chat button), the pane is automatically scrolled down to the latest message i.e auto-scroll. This is a common requirement for chat apps such as WhatsApp, Skype, iMessage. As you send more messages the pane auto scrolls so you don’t have to do so manually.

I explain how this works in a previous write up on lifecycle methods, but I’m happy to do a simple recap.

Recap: How getSnapshotBeforeUpdate works for auto-scroll

In a nutshell, you check if there are new chat messages and return the dimension to be scrolled within the getSnapshotBeforeUpdate lifecycle method as shown below:

getSnapshotBeforeUpdate (prevProps, prevState) {
    if (this.state.chatList > prevState.chatList) {
      const chatThreadRef = this.chatThreadRef.current
      return chatThreadRef.scrollHeight - chatThreadRef.scrollTop
    }
    return null
  }

Here’s how the code snippet above works.

First, consider a situation where the entire height of all chat messages doesn’t exceed the height of the chat pane.

chat message scroll height

Here, the expression chatThreadRef.scrollHeight - chatThreadRef.scrollTop will be equivalent to chatThreadRef.scrollHeight - 0.

When this is evaluated, the returned value from getSnapshotBeforeUpdate will be equal to the scrollHeight of the chat pane — just before the new message is inserted to the DOM.

If you remember how getSnapshotBeforeUpdate works, the value returned from the getSnapshotBeforeUpdate method is passed as the third argument to the componentDidUpdate method.

We call this value, snapshot:

componentDidUpdate(prevProps, prevState, snapshot) {

}

The snapshot value passed in here — at this time, is the previous scrollHeight before the DOM is updated.

In the componentDidUpdate lifecycle method, here’s the code that updates the scroll position of the chat pane:

componentDidUpdate(prevProps, prevState, snapshot) {
    if (snapshot !== null) {
      const chatThreadRef = this.chatThreadRef.current;
      chatThreadRef.scrollTop = chatThreadRef.scrollHeight - snapshot;
    }
  }

In actuality, we are programmatically scrolling the pane vertically from the top down, by a distance equal to chatThreadRef.scrollHeight - snapshot.

Since snapshot refers to the scrollHeight before the update, the above expression returns the height of the new chat message plus any other related height owing to the update. Please see the graphic below:

scroll height before update

When the entire chat pane height is occupied with messages (and already scrolled up a bit), the snapshot value returned by the getSnapshotBeforeUpdate method will be equal to the actual height of the chat pane.

chat pane height

The computation from componentDidUpdate will set the scrollTop value to the sum of the heights of extra messages – exactly what we want.

And, that’s it!

How do we replicate this with Hooks?

The goal here is to try as much as possible to recreate a similar API using Hooks. While this is not entirely possible, let’s give it a shot!

To implement getSnapshotBeforeUpdate with Hooks, we’ll write a custom Hook called useGetSnapshotBeforeUpdate and expect to be invoked with a function argument like this:

useGetSnapshotBeforeUpdate(() => {

})

The class lifecycle method, getSnapshotBeforeUpdate gets called with prevProps and prevState. So we’d expect the function passed to useGetSnapshotBeforeUpdate to be invoked with the same arguments.

useGetSnapshotBeforeUpdate((prevProps, prevState) => {

})

There’s simply no way to get access to prevProps and prevState except by writing a custom solution. One approach involves the user passing down the current props and state to the custom Hook, useGetSnapshotBeforeUpdate. The Hook will accept two more arguments, props and state – from these, we will keep track of prevProps and prevState within the Hook.

useGetSnapshotBeforeUpdate((prevProps, prevState) => {

}, props, state)

Now let’s write the internals of the useGetSnapshotBeforeUpdate Hook by getting a hold of the previous props and state.

// custom Hook for getting previous props and state
// https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
const usePrevPropsAndState = (props, state) => {
  const prevPropsAndStateRef = useRef({ props: null, state: null })
  const prevProps = prevPropsAndStateRef.current.props
  const prevState = prevPropsAndStateRef.current.state

  useEffect(() => {
    prevPropsAndStateRef.current = { props, state }
  })

  return { prevProps, prevState }
}

// actual hook implementation 
const useGetSnapshotBeforeUpdate = (cb, props, state) => {
  // get prev props and state
  const { prevProps, prevState } = usePrevPropsAndState(props, state)

}

As seen above, the useGetSnapshotBeforeUpdate Hook takes the user callback, props, and state as arguments then invokes the usePrevPropsAndState custom Hook to get a hold of the previous props and state.

Next, it is important to understand that the class lifecycle method, getSnapshotBeforeUpdate is never called on mount. It is only invoked when the component updates. However, the Hooks useEffect and useLayoutEffect are by default, always called at least once on mount. We need to prevent this from happening.

Here’s how:

const useGetSnapshotBeforeUpdate = (cb, props, state) => {
  // get prev props and state
  const { prevProps, prevState } = usePrevPropsAndState(props, state)

        
// getSnapshotBeforeUpdate - not run on mount + run on every update
  const componentJustMounted = useRef(true)
  useLayoutEffect(() => {
    if (!componentJustMounted.current) {
      // do something
    }
    componentJustMounted.current = false
  })
}

To prevent useLayoutEffect from running on mount we keep hold of a ref value componentJustMounted which is true by default and only set to false at least once after useLayoutEffect is already fired.

If you paid attention, you’d notice I used the useLayoutEffect Hook and not useEffect. Does this matter?

Well, there’s a reason why I did this.

The class lifecycle method getSnapshotBeforeUpdate returns a snapshot value that is passed on to the componentDidUpdate method. However, this snapshot is usually value retrieved from the DOM before React has had the chance to commit the changes to the DOM.

Since useLayoutEffect is always fired before useEffect, it is the closest we can get to retrieving a value from the DOM before the browser has had the chance to paint the changes to the screen.

Also, note that the useLayoutEffect Hook is NOT called with any array dependencies – this makes sure it fires on every update/re-render.

Let’s go ahead and get the snapshot. Note that this is the value returned from invoking the user’s callback.

const useGetSnapshotBeforeUpdate = (cb, props, state) => {
  // get prev props and state
  const { prevProps, prevState } = usePrevPropsAndState(props, state)
  // 👇 look here
  const snapshot = useRef(null)


// getSnapshotBeforeUpdate - not run on mount + run on every update
  const componentJustMounted = useRef(true)
  useLayoutEffect(() => {
    if (!componentJustMounted.current) {
      // 👇 look here
           snapshot.current = cb(prevProps, prevState)  
    }
    componentJustMounted.current = false
  })
}

So far, so good.

The concluding part of this solution involves accommodating for componentdidUpdate since it is closely used with getSnapshotBeforeUpdate.

Remember, the componentdidUpdate lifecycle method is invoked with prevProps, prevState, and the snapshot returned from getSnapshotBeforeUpdate.

To mimic this API we will have the user call a custom useComponentDidUpdate Hook with a callback:

useComponentDidUpdate((prevProps, prevState, snapshot) => {

})

How do we do this? One solution is to return the useComponentDidUpdate Hook from the useGetSnapshotBeforeUpdate Hook earlier built. Yes, a custom Hook can return another! By doing this we take advantage of JavaScript closures.

Here’s the implementation of that:

const useGetSnapshotBeforeUpdate = (cb, props, state) => {
  // get prev props and state
  const { prevProps, prevState } = usePrevPropsAndState(props, state)

  const snapshot = useRef(null)


// getSnapshotBeforeUpdate - not run on mount + run on every update
  const componentJustMounted = useRef(true)
  useLayoutEffect(() => {
    if (!componentJustMounted.current) {
           snapshot.current = cb(prevProps, prevState)  
    }
    componentJustMounted.current = false
  })

 // 👇 look here
 const useComponentDidUpdate = cb => {
    useEffect(() => {
      if (!componentJustMounted.current) {
        cb(prevProps, prevState, snapshot.current)
      }
    })
  }
  // 👇 look here
  return useComponentDidUpdate
}

There are a couple things to note from the code block above. First, we also prevent the user callback from being triggered when the component just mounts — since componentDidUpdate isn’t invoked on mount.

Also, we use the useEffect Hook here and not useLayoutEffect.

And that is it! We’ve made an attempt to reproduce the APIs for getSnapshotBeforeUpdate, but does this work?

Testing out the implemented solution

We may now refactor the App component from the demo to use Hooks. This includes using the custom Hooks we just built like this:

const App = props => {
  // other stuff ...

  const useComponentDidUpdate = useGetSnapshotBeforeUpdate(
    (_, prevState) => {
      if (state.chatList > prevState.chatList) {
        return (
          chatThreadRef.current.scrollHeight - chatThreadRef.current.scrollTop
        )
      }
      return null
    },
    props,
    state
  )

  useComponentDidUpdate((prevProps, prevState, snapshot) => {
    console.log({ snapshot }) // 👈 look here
    if (snapshot !== null) {
      chatThreadRef.current.scrollTop =
        chatThreadRef.current.scrollHeight - snapshot
    }
  })
}

The implementation within these Hooks is just the same as the class component. However, note that I’ve logged the snapshot received from our custom implementation.

From the implementation with class lifecycle methods here’s what you get:

scrollTop

The snapshot is indeed received before the React commits to the DOM. We know this because the snapshot refers to the scrollHeight before the actual update and in the screenshot, it is obviously different from the current scrollHeight.

However with our Hooks implementation, the previous scrollHeight which is, in fact, the snapshot we seek, is never different from the current scrollHeight.

snapshot and scrollTop

 

For some reason, we’re unable to catch the snapshot before the DOM is updated. Why’s this the case?

Conclusion

While it may seem insignificant, this exercise is great for questioning your understanding of Hooks and certain React fundamentals. In a nutshell, we’re unable to get a hold of the snapshot before the DOM is updated because all Hooks are invoked in the React “commit phase” – after React has updated the DOM and refs internally.

lifecycle methods diagram
http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

Since getSnapshotBeforeUpdate is invoked before the commit phase this makes it impossible to be replicated within the confines of just the Hooks, useEffect and useLayoutEffect.

I hope you enjoyed the discourse and learned something new. Stay up to date with my writings.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, tracking slow network requests and component load time, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors performance of your app with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps - .

Ohans Emmanuel Author, Understanding Redux. I Love God. I Love GF a little too much 💕🤣 http://thereduxjsbooks.com

Leave a Reply