skip to Main Content

I am writing a custom hook that accepts a ref. I would like to attach an event listener to this DOM node and remove the listener inside a cleanup function.

I started with something like this:

useIsFocused.ts

function useIsFocused(elem: React.MutableRefObject<HTMLElement | null>) {
  const [hasFocus, setHasFocus] = useState(false);

    useEffect(() => {
    const watched = elem.current;

    function setFocus() {
      setHasFocus(true);
    }
    function removeFocus() {
      setHasFocus(false);
    }

    if (watched) {
      // set initial value
      if (watched === document.activeElement) {
        setHasFocus(true);
      }

      // watch
      watched.addEventListener('focusin', setFocus);
      watched.addEventListener('focusout', removeFocus);

      // cleanup
      return () => {
        watched.removeEventListener('focusin', setFocus);
        watched.removeEventListener('focusout', removeFocus);
      };
    }
  }, [elem]);
}

That worked at first. However, once we started conditionally rendering the input elem, it stopped working. As the DOM node is destroyed and re-created, the handlers are not being cleaned up or re-attaching to the new node. After some digging, I realized it is because of the way refs work, since they are not part of re-renders. Of course, the useEffect is not triggering if the ref.current value changes. Therefore, my hook isn’t responding to the changes in the DOM node.

Ideally, I’d like to use it like this:

App.tsx

function App() {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const inputIsFocused = useIsFocused(inputRef);
  const [showInput, setShowInput] = useState(true);

  return (
    <div>
      { showInput &&
        <input type="text" value={search} ref={inputRef} />
      }
      <p>Is focused? {JSON.stringify(inputisFocused)} </p>
      <button onClick={()=> setShowInput((e) => !e)}>Toggle input</button>
    </div>
  );
}

My question is, what is the proper way to make a reusable, custom hook that cleanly attaches a listener to a DOM node, reacts to changes (such as mounting/unmounting the node) and handles cleanup? Emphasis on a clean DX implementation for the implementing component.

Related questions

2

Answers


  1. As you already mentioned, refs do not trigger re-render. However, applying a ref to a component (or HTML element) will update the ref on each re-render (asymptotically). So…. lets match the update cycle instead.

    function useIsFocused(elem: React.MutableRefObject<HTMLElement | null>) {
      const [hasFocus, setHasFocus] = useState(false)
    
      const setFocus = useCallback(() => setHasFocus(true), [])
      const removeFocus = useCallback(() => setHasFocus(false), [])
      useEffect(() => {
        const watched = elem.current
    
        if (watched) {
          // set initial value
          if (watched === document.activeElement) {
            setHasFocus(true)
          }
    
          // watch
          watched.addEventListener('focusin', setFocus)
          watched.addEventListener('focusout', removeFocus)
    
          // cleanup
          return () => {
            watched.removeEventListener('focusin', setFocus)
            watched.removeEventListener('focusout', removeFocus)
          }
        }
      })
    }
    

    👆 Apply the events on every re-render by removing the dependency array on useEffect

    Bonus tip why don’t you make the hook return a tupple [refObject, hasFocus]. Since you are in a custom hook, you can create a ref object within the hook

    Login or Signup to reply.
  2. I’d suggest staying away from useRef and useEffect as much as possible.

    Example 1: useState only

    import { useState } from 'react'
    
    function useFocusManager() {
      const [focused, setFocused] = useState(false);
    
      const onFocus = () => setFocused(true);
      const onBlur = () => setFocused(false);
    
      return { focused, onFocus, onBlur };
    }
    
    function App() {
      const inputFocusManager = useFocusManager();
      const [showInput, setShowInput] = useState(true);
    
      return (
        <div className="App">
          <p>Is focused? - {inputFocusManager.focused ? "yes" : "no"} </p>
    
          {showInput && (
            <div>
              <input
                type='text'
                onFocus={inputFocusManager.onFocus}
                onBlur={inputFocusManager.onBlur}
              />
            </div>
          )}
    
          <div>
            <button onClick={()=> setShowInput(() => !showInput)}>Toggle input</button>
          </div>
        </div>
      )
    }
    
    export default App
    

    Example 2: with event listeners wo useRef (upon request)

    Switching useRef to useState to trigger re-render to trigger useEffect to add event listeners when inputRef is assigned.

    useRef doesn’t provide re-render capability by design.

    import { useEffect, useState } from 'react'
    
    function useIsFocused(elemRef: HTMLInputElement | undefined) {
      const [focused, setFocused] = useState(false);
    
      function setFocus() {
        setFocused(true);
      }
    
      function removeFocus() {
        setFocused(false);
      }
    
      useEffect(() => {
        console.log('elemRef', elemRef);
    
        if (elemRef) {
          elemRef.addEventListener('focusin', setFocus);
          elemRef.addEventListener('focusout', removeFocus);
    
          return () => {
            elemRef.removeEventListener('focusin', setFocus);
            elemRef.removeEventListener('focusout', removeFocus);
          };
        }
      }, [elemRef]);
    
      return focused;
    }
    
    function App() {
      const [inputRef, setInputRef] = useState<HTMLInputElement>();
      const [showInput, setShowInput] = useState(true);
      const isInputFocused = useIsFocused(inputRef);
    
      return (
        <div className="App">
          <p>Is focused? - {isInputFocused ? "yes" : "no"} </p>
          {showInput && <input ref={(ref) => setInputRef(ref ?? undefined)} type='text' />}
          <button onClick={()=> setShowInput(() => !showInput)}>Toggle input</button>
        </div>
      )
    }
    
    export default App
    

    Example 3: with event listeners w useRef (upon request)

    resetCounter update triggers re-render which triggers useEffect which adds new event listeners

    import { RefObject, useEffect, useRef, useState } from 'react'
    
    function useIsFocused(elemRef: RefObject<HTMLInputElement>) {
      const [focused, setFocused] = useState(false);
      const [resetCounter, setResetCounter] = useState(0);
    
      function reset() {
        setResetCounter(resetCounter + 1);
      }
    
      function setFocus() {
        setFocused(true);
      }
    
      function removeFocus() {
        setFocused(false);
      }
    
      useEffect(() => {
        console.log('elemRef', elemRef.current);
    
        elemRef.current?.addEventListener('focusin', setFocus);
        elemRef.current?.addEventListener('focusout', removeFocus);
    
        // cleanup
        return () => {
          console.log('cleanup');
    
          elemRef.current?.removeEventListener('focusin', setFocus);
          elemRef.current?.removeEventListener('focusout', removeFocus);
        };
      }, [resetCounter]);
    
      return [focused, reset] as const;
    }
    
    function App() {
      const inputRef = useRef<HTMLInputElement>(null);
      const [showInput, setShowInput] = useState(true);
    
      const [isInputFocused, resetInputFocused] = useIsFocused(inputRef);
    
      return (
        <div className="App">
          <p>Is focused? - {isInputFocused ? "yes" : "no"} </p>
          {showInput && <input ref={inputRef} type='text' />}
          <button
            onClick={()=> {
              setShowInput(() => !showInput);
              resetInputFocused();
            }}
          >
            Toggle input
          </button>
        </div>
      )
    }
    
    export default App
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search