DEV Community

Cover image for Avoiding useEffect with callback refs
Dominik D
Dominik D

Posted on • Originally published at tkdodo.eu

Avoiding useEffect with callback refs

Note: This article assumes a basic understanding of what refs are in React.

Even though refs are mutable containers where we can theoretically store arbitrary values, they are most often used to get access to a DOM node:

const ref = React.useRef(null)

return <input ref={ref} defaultValue="Hello world" />
Enter fullscreen mode Exit fullscreen mode

ref is a reserved property on build-in primitives, where React will store the DOM node after it was rendered. It will be set back to null when the component is unmounted.

Interacting with refs

For most interactions, you don't need to access the underlying DOM node, because React will handle updates for us automatically. A good example where you might need a ref is focus management.

There's a good RFC from Devon Govett that proposes adding FocusManagement to react-dom, but right now, there is nothing in React that will help us with that.

Focus with an effect

So how would you, right now, focus an input element after it rendered? (I know autofocus exists, this is an example. If this bothers you, imagine you'd want to animate the node instead.)

Well, most code I've seen tries to do this:

const ref = React.useRef(null)

React.useEffect(() => {
  ref.current?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />
Enter fullscreen mode Exit fullscreen mode

This is mostly fine and doesn't violate any rules. The empty dependency array is okay because the only thing used inside is the ref, which is stable. The linter won't complain about adding it to the dependency array, and the ref is also not read during render (which might be troublesome with concurrent react features).

The effect will run once "on mount" (twice in strict mode). By that time, React has already populated the ref with the DOM node, so we can focus it.

Yet this is not the best way to do it and does have some caveats in some more advanced situations.

Specifically, it assumes that the ref is "filled" when the effect runs. If it's not available, e.g. because you pass the ref to a custom component which will defer the rendering or only show the input after some other user interaction, the content of the ref will still be null when the effect runs and nothing will be focussed:

function App() {
  const ref = React.useRef(null)

  React.useEffect(() => {
    // 🚨 ref.current is always null when this runs
    ref.current?.focus()
  }, [])

  return <Form ref={ref} />
}

const Form = React.forwardRef((props, ref) => {
  const [show, setShow] = React.useState(false)

  return (
    <form>
      <button type="button" onClick={() => setShow(true)}>
        show
      </button>
      // 🧐 ref is attached to the input, but it's conditionally rendered
      // so it won't be filled when the above effect runs
      {show && <input ref={ref} />}
    </form>
  )
})
Enter fullscreen mode Exit fullscreen mode

Here is what happens:

  • Form renders.
  • input is not rendered, ref is still null.
  • effect runs, does nothing.
  • input is shown, ref will be filled, but will not be focussed because effect won't run again.

The problem is that the effect is "bound" to the render function of the Form, while we actually want to express: "Focus the input when the input is rendered", not "when the form mounts".

Callback refs

This is where callback refs come into play. If you've ever looked at the type declarations for refs, we can see that we can not only pass a ref object into it, but also a function:

type Ref<T> = RefCallback<T> | RefObject<T> | null
Enter fullscreen mode Exit fullscreen mode

Conceptually, I like to think about refs on react elements as functions that are called after the component has rendered. This function gets the rendered DOM node passed as argument. If the react element unmounts, it will be called once more with null.

Passing a ref from useRef (a RefObject) to a react element is therefore just syntactic sugar for:

<input
  ref={(node) => {
    ref.current = node;
  }}
  defaultValue="Hello world"
/>
Enter fullscreen mode Exit fullscreen mode

Let me emphasize this once more:

All ref props are just functions!

And those functions run after rendering, where it is totally fine to execute side effects. Maybe it would have been better if ref would just be called onAfterRender or something.

With that knowledge, what stops us from focussing the input right inside the callback ref, where we have direct access to the node?

<input
  ref={(node) => {
    node?.focus()
  }}
  defaultValue="Hello world"
/>
Enter fullscreen mode Exit fullscreen mode

Well, a tiny detail does: React will run this function after every render. So unless we are fine with focussing our input that often (which we are likely not), we have to tell React to only run this when we want to.

useCallback to the rescue

Luckily, React uses referential stability to check if the callback ref should be run or not. That means if we pass the same ref(erence, pun intended) to it, execution will be skipped.

And that is where useCallback comes in, because that is how we ensure a function is not needlessly created. Maybe that's why they are called callback-refs - because you have to wrap them in useCallback all the time. 😂

Here's the final solution:

const ref = React.useCallback((node) => {
  node?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />
Enter fullscreen mode Exit fullscreen mode

Comparing this to the initial version, it's less code and only uses one hook instead of two. Also, it will work in all situations because the callback ref is bound to the lifecycle of the dom node, not of the component that mounts it. Further, it will not execute twice in strict mode (when running in the development environment), which seems to be important to many.

And as shown in this hidden gem in the (old) react docs, you can use it to run any sort of side effects, e.g. call setState in it. I'll just leave the example here because it's actually pretty good:

function MeasureExample() {
  const [height, setHeight] = React.useState(0)

  const measuredRef = React.useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height)
    }
  }, [])

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

So please, if you need to interact with DOM nodes directly after they rendered, try not to jump to useRef + useEffect directly, but consider using callback refs instead.


That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below. ⬇️

Top comments (9)

Collapse
 
rounakcodes profile image
rounakcodes

Many times when I read React docs or blogs from people closer to the React team, I find them briefly referring to some use case or some kind of components suitable for a React API without actually explaining the same in depth. I fail to confidently understand their discussion due to the incomplete context. I appreciate that you have taken the pain to demonstrate everything to the last detail. I would like to read more such articles. Thanks.

Collapse
 
fjones profile image
FJones

The same holds true for what not to use specific APIs for. The useEffect doc, for instance, discourages the use for data fetching, except it only gave vague indications to use event handlers instead, last time I read it. Sometimes, conceptualizing what the intended solution would be becomes rather difficult indeed.
This post does a much better job explaining callback refs (although a bit more detail on why ref uses referential integrity to determine whether to rerun would be nice)

Collapse
 
smlka profile image
Andrey Smolko

Good job! I would like to add my 2 cents. You mentioned that "React will run this [incline] function after every render". I would say that such inline functions will be called twice per rerender (null as argument then node as argument). Technically a prev function instance will be called with null (ref is unset) and a new function instance will be called with a node (ref is set):

import {useState} from 'react'

export default function MeasureExample() {
  const [height, setHeight] = useState(0)
  const [h, setH] = useState(0)

  const measuredRef = (node => {
    console.log(node, h)
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height)
    }
  })

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2 key={h}>The above header is {Math.round(height)}px tall</h2>
      <button onClick={()=>{setH(h+1)}}>Click</button>
    </>
  )
}

// after click
// null 0 - pre ref is unset, prev value of state is used
// <h1>Hello world</h1> 1 - new ref is set, new value of state is used
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tkdodo profile image
Dominik D

You're right, it will run once with null for the previous node, and then once more with the new node.

There are apparently some plans to "fix" callback refs by allowing them to return a cleanup function like useEffect does: twitter.com/dan_abramov/status/155...

Collapse
 
tkdodo profile image
Dominik D

But you also said this is a "good example", and it isn't:

I said:

A good example where you might need a ref is focus management.

And it is! Focus management is so much more than "autofocus". Please read the attached RFC: github.com/devongovett/rfcs-1/blob...

To achieve this, you need refs at the moment. I merely picked "autofocus" because it's a cheap way to show a side-effect, and it's also what the react beta docs use to show how to programmatically focus a node:

beta.reactjs.org/learn/manipulatin...

And I added the paragraph to avoid discussion about "just use the autofocus attribute" 😅


I like the useBoundingClientRect hook example. I'm pretty sure it will still use "callback refs" because you'll likely attach the setElement function to your react element via a ref?

const [rect, setElement] = useBoundingClientRect()

<div ref={setElement} />
Enter fullscreen mode Exit fullscreen mode

this approach also uses "callback refs" - it's just that the whole dom node is stored in react state :)

Collapse
 
monfernape profile image
Usman Khalil

I saw your tweet the other day explaining this but I couldn't consume it effectively. This writing explains it very well. Also, sharing a use case has definitely helped knowing where it could be used. Thanks you.

Collapse
 
joaozitopolo profile image
Joao Polo

Dominik... simply awesome!!! Thanks for share.
I really thought ref had a "protocol" and you couldn't break.
It opened my mind.

Collapse
 
brense profile image
Rense Bakker

Genius...