How to Shoot Yourself in the Foot in React
by Combining useEffect
with useRef
TL;DR
useEffect
runs after render. ⏱- When using SSR, conditional rendering based on window can break hydration. 💧
- In German we have a figure of speech that literally translates to “Where is the hook?” which means “What is the catch?”. 🎣
Hooks are Awesome!
They truly are. But code using hooks can be misleading unless you understand how hooks like useEffect
exactly behave.
Do you see the problem in this example?
const MyComponent = ({ input }) => {
const someRef = useRef("")
useEffect(() => {
someRef.current = expensiveCalculation(input)
}, [input])
return <SomeChildComponent text={someRef.current} />
}
What I wanted to do is to only execute expensiveCalculation
on the client side after hydration, taking advantage of useEffect
being skipped on SSR (server-side-rendering, e.g. in Next.js or Gatsby.js).
If you think the code looks fine, and someRef.current
is updated before being passed down to SomeChildComponent
, you are not alone. But you are wrong. I thought so too until I found out how it behaved 😱:
💡 The problem is that useEffect
runs after render!
What does
useEffect
do? By using this Hook, you tell React that your component needs to do something after render. React will remember the function you passed (we’ll refer to it as our “effect”), and call it later after performing the DOM updates.
That is the reason why SomeChildComponent
is being rendered with an outdated text
prop in the above example. The previous value of someRef.current
will be used for rendering SomeChildComponent
, and only after that, the useEffect
hook runs and updates someRef.current
. There is no automatic re-render after updating a ref, so the old value will be displayed until the component is rendered again, for example when its parent component re-renders or when its internal state changes.
Same Problem, Different Hook
The same problem can happen in custom hooks, for example when returning a ref‘s current
value from the hook:
const useMyHook = ({ input }) => {
const someRef = useRef("")
useEffect(() => {
someRef.current = expensiveCalculation(input)
}, [input])
return someRef.current
}
This hook will return the previous value of someRef.current
until the next re-render.
Why Isn’t My App More Broken Then?
The problem often does not have an immediate impact because re-renders might be triggered by other updates in your components. The bug is "masked" and waits for the next refactoring to jump out of hiding.
Consider this case:
const MyComponent = ({ input }) => {
const [someState, setSomeState] = useState(0)
const someRef = useRef("")
useEffect(() => {
setSomeState(oldState => oldState + 1)
someRef.current = expensiveCalculation(input)
}, [input])
return <SomeChildComponent text={someRef.current} />
}
Note that this is not a suggested solution, but an example of why the bug might be masked.
This works correctly, because setting the state inside of useEffect
will render the component twice every time the input
prop changes, fixing the text
prop in the second render:
While this works, it is not very resilient and might break as soon as future-you (or future-someone-else) refactors the code. Furthermore, duplicate renders cause a small performance penalty, and we want to avoid death by a thousand cuts.
OK, Let’s Fix It Properly Then
To fix the issue, it depends on why you needed the useEffect
and useRef
in the first place. I'll concentrate on a main use case of such useEffect
s in SSR apps: skip something to be rendered on SSR, and only render it after hydration. To fix it and also avoid double renders every time the input
prop changes, it‘s possible to combine useEffect
, setState
and useMemo
:
const MyComponent = ({ input }) => {
const isMounted = useIsMounted()
const text = useMemo(() => (
isMounted ? expensiveCalculation(input) : undefined
),
[isMounted, input])
return <SomeChildComponent text={text} />
}const useIsMounted = () => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
return isMounted
}
This works as expected for SSR, because useEffect
is not executed on the server, only after hydration on the client. That means the useIsMounted
hook causes a single re-render after hydration by setting the state using setIsMounted
. The empty dependency array []
on the useEffect
hook guarantees that the hook will only trigger a re-render once after hydration. (Do not omit the []
, useEffect
without the second parameter runs on every render, which would cause unnecessary re-renders in our case).
Now this works better and only renders once for every click on the "Increment" button:
Let's Use a Different Footgun!
You could fall into the trap of just checking for typeof window
to render something different in SSR and after hydration:
const MyComponent = ({ input }) => {
const text = typeof window === "undefined" ? 0 : expensiveCalculation(input)
return <SomeChildComponent text={text} />
}
⚠️ This could break your app! Seriously, never do conditional renders based on things like window
or document
or anything that could be different between client and server. The React documentation explicitly forbids that:
React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them. In development mode, React warns about mismatches during hydration. There are no guarantees that attribute differences will be patched up in case of mismatches. This is important for performance reasons because in most apps, mismatches are rare, and so validating all markup would be prohibitively expensive.
Rather use the useIsMounted
hook, the re-render after hydration is necessary for such conditional renders.
Josh Comeau has written about this in more detail in a great article.
Final Thoughts
Hooks in React are great and help a lot with simplifying code and breaking it up into composable parts. Nevertheless, they have their pitfalls. Make sure to know your tools and really understand what your code does. Documentation is your friend! And check out Dan Abramov's awesome deep-dive article into useEffect
!
Anything Else? Did I miss your favourite React footgun? Tell me in the comments!
Looking for an awesome workplace in Vienna? We are always searching for motivated and skilled team players. jobsbei.willhaben.at