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?
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
.
To make this as pragmatic as possible we’ll work with the following demo 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.
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.
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:
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.
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!
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?
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:
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
.
For some reason, we’re unable to catch the snapshot before the DOM is updated. Why’s this the case?
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.
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
2 Replies to "How is getSnapshotBeforeUpdate implemented with Hooks?"
Hey, nice article! I noticed that `useLayoutEffect` did not work for you as it really happens right *after* DOM rendering. The regular `useEffect` is even more delayed — it happens in the next animation frame after rendering.
What you could do was to make your snapshot right in the component function body. This is what actually gets called before DOM manipulations. You already have your new state/props and you can use refs to get previous ones.
This article, however nicely marked-up it is, is actually useless because `useLayoutEffect` is called right *after* DOM rendering, not before it.