How to Shoot Yourself in the Foot in React

by Combining useEffect with useRef

Fabian
willhaben Tech Blog
5 min readFeb 23, 2020

--

TL;DR

  1. useEffect runs after render. ⏱
  2. When using SSR, conditional rendering based on window can break hydration. 💧
  3. In German we have a figure of speech that literally translates to “Where is the hook?” which means “What is the catch?”. 🎣
react logo

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 😱:

SomeChildComponent somehow is not up-to-date — What is the catch with hooks 🎣?

💡 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.

Source: https://reactjs.org/docs/hooks-effect.html

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:

Works correctly, but re-renders twice for every click on the "Increment" button

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 useEffects 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:

Works correctly without unnecessary re-renders

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.

Source: https://reactjs.org/docs/react-dom.html

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

--

--