DEV Community

Janice
Janice

Posted on

React Callback Refs

React refs are used when you want to update a value between renders but do not want the update to trigger rerendering. Here's one of the examples: useIsFirstRender returns true only if it is on the first render.

function useIsFirstRender(): boolean {
    const isFirstRender = useRef(true);

    useEffect(() => {
        isFirstRender.current = false;

        return () => {
            isFirstRender.current = true;
        }
    }, []);

    return isFirstRender.current;
}
Enter fullscreen mode Exit fullscreen mode

Making isFirstRender a ref object would be better than making it a state, because modifying a state could potentially trigger other side effects unnecessarily during rerenders.

Now let's take a look at another example of useRef. We need to customize a hook useFocus. useFocus returns a ref object to be assigned to an input element and a boolean isFocus indicating if the input is currently being focused.

function useFocus<T extends HTMLElement>(): [Ref<T>, boolean];

function App() {
  const [ref, isFocused] = useFocus<HTMLInputElement>()
  return <div>
    <input ref={ref}/>
    {isFocused && <p>focused</p>}
  </div>
}
Enter fullscreen mode Exit fullscreen mode

At the first glance, you might be thinking to use a useEffect block, so we can add focus & blur event listeners from the input in the useEffect, and remove the listeners in a cleanup function. However, this would not work out well in the case that we have two inputs in the same component and the ref could potentially be switched from one input the other input. Let's visualise what I mean:

export function App() {
    const [buttonLabel, setButtonLabel] = useState(0);
    const toggle = () => setButtonLabel(buttonLabel ? 0 : 1);
    const [ref, isFocused] = useFocus<HTMLInputElement>()

    return <div>
                <input ref={buttonLabel ? null: ref}/>
                <input ref={buttonLabel ? ref : null} />
                <button onClick={toggle}>Toggle input</button>
                {isFocused && <p>focused</p>}
    </div>
}
Enter fullscreen mode Exit fullscreen mode

As you can see, when the ref changes, we need a way to force the useEffect cleanup function to run, otherwise it will cause the problem where the event listeners retain in the previous input.

Introducing Callback Refs

Here is when ref callbacks become useful.

<div ref={(node) => console.log(node)} />
Enter fullscreen mode Exit fullscreen mode

By default ref callbacks are invoked when you pass in a new ref callback. Basically this happens in the following scenarios:

  1. The component mounts
  2. The component unmounts
  3. The component rerenders

Now back to our example, here's the solution with ref callbacks:

  const refCallback = useCallback((node: T) => {
    if(inputRef.current) {
      inputRef.current.removeEventListener('focus', handleFocus);
      inputRef.current.removeEventListener('blur', handleBlur);   
    }

    if(node) {
      node.addEventListener('focus', handleFocus);
      node.addEventListener('blur', handleBlur);
    }

    inputRef.current = node;
  }, [])
Enter fullscreen mode Exit fullscreen mode

We wrap the ref callback with useCallback to avoid the callback being triggered every time the component rerenders. E.g, when the user focuses/unfocuses on the input. Now when the ref changes, node changes from the old input to undefined, then takes the new input. The logic sequence goes:

  1. Remove the listeners from the old input
  2. inputRef.current becomes undefiend
  3. Attach the listeners to the new input
  4. Assign the new input to inputRef.current

Here is the full solution:

import React, { Ref, useState, useRef, useCallback } from 'react'

export function useFocus<T extends HTMLElement>(): [Ref<T>, boolean] {
  const [isFocused, setFocused] = useState(false);
  const input = useRef<T>();

  const handleOnFocus = useCallback(() => setFocused(true), []);
  const handleOnBlur = useCallback(() => setFocused(false), []);

  const inpurRefCallback = useCallback((node: T) => {
    if (input.current) {
      // cleanup listeners on previous ref
      input.current.removeEventListener('focus', handleOnFocus);
      input.current.removeEventListener('blur', handleOnBlur);
    }

    if (node) {
      // Add listeners on new ref
      node.addEventListener('focus', handleOnFocus);
      node.addEventListener('blur', handleOnBlur);
    }

    // save new ref
    input.current = node;
  }, [])

  return [inpurRefCallback, isFocused]
}
Enter fullscreen mode Exit fullscreen mode

Ref callbacks would be a perfect usage in the custom hooks like useFocus and useHover etc. It gives you more control over your implementation when the node changes.

I hope you have learnt something from this post. Feel free to post any questions in the comment section below.

Bonus

There's also a solution if you really want to use useEffect to solve the problem in the example. Here's the code:

export function useFocus<T extends HTMLElement>(): [Ref<T | undefined>, boolean] {
  const [isFocused, setFocused] = useState(false)
  const ref = useRef<T>()
  useEffect(() => {
    const currentElement = ref.current
      if (!currentElement)
      return

    // initialize the focus state when currentElement changes.
    setFocused(document.activeElement === currentElement)

    const onFocus = () => setFocused(true)
    const onBlur = () => setFocused(false)

    currentElement.addEventListener('focus', onFocus)
    currentElement.addEventListener('blur', onBlur)

    return () => {
      currentElement.removeEventListener('focus', onFocus)
      currentElement.removeEventListener('blur', onBlur)
    }
  }, [ref.current]) // now we can pass a dependency array to get much better performance.

  return [ref, isFocused]
}
Enter fullscreen mode Exit fullscreen mode

However, I personally would not recommend this approach because when you specify ref.current in the useEffect's dependency array, how side effect updates immedicately becomes unintuitive. For example, with the above code on the first render:

  • useEffect gets triggered, and it double sets focused to false
  • attach the event listeners

When the user focuses on the input for the first time:

  • useEffect gets triggered again since ref.current has changed from undefined to the input, and it double sets focused to true

When the user focuses on the input after the first time:

  • useEffect won't be triggered again

The problem is that the value of ref in the deps array is not in sync with the one in the useEffect's body.

Top comments (0)