Written by Ohans Emmanuel✏️
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:
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.
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!
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:
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?
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.
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.
Editor's note: Seeing something wrong with this post? You can find the correct version here).
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
Try it for free.
The post How is getSnapshotBeforeUpdate implemented with Hooks? appeared first on LogRocket Blog.
Top comments (0)