1We are asked to create a ref, let's start with useRef().
2
3import { Ref, useRef, useState } from 'react'
4export function useHover<T extends HTMLElement>(): [Ref<T>, boolean] {
5 const ref = useRef<T | null>(null)
6 const [isHovered] = useState(false)
7 return [ref, isHovered]
8}
9
10In order to attach event listeners, we can look at ref.current and set up listeners in useEffect.
11
12import { Ref, useRef, useState, useEffect } from 'react'
13export function useHover<T extends HTMLElement>(): [Ref<T>, boolean] {
14 const ref = useRef<T | null>(null)
15 const [isHovered, setIsHovered] = useState(false)
16 useEffect(() => {
17 const element = ref.current
18 if (element) {
19 const onMouseEnter = () => setIsHovered(true)
20 const onMouseLeave = () => setIsHovered(false)
21 element.addEventListener('mouseenter', onMouseEnter, false)
22 element.addEventListener('mouseleave', onMouseLeave, false)
23 return () => {
24 element.removeEventListener('mouseenter', onMouseEnter, false)
25 element.removeEventListener('mouseleave', onMouseLeave, false)
26 }
27 }
28 }, [ref.current])
29 return [ref, isHovered]
30}
31
32At first glance, it seems to work, but it actually doesn't. It is a bad idea to use ref.current as useEffect dependency.
33
34The update of ref.current is silent - it doesn't trigger component updates so we are unable to detect its change correctly in useEffect.
35
36Let's break it down for the 2nd test case.
37
38function App() {
39 const [ref, isHovered] = useHover<HTMLDivElement>()
40 const [refTarget, setRefTarget] = useState<number>(0)
41 return (
42 <div>
43 <p>{isHovered ? 'hovered' : 'not hovered'}</p>
44 <button
45 data-testid="change-ref-target-button"
46 onClick={() => {
47 setRefTarget((target) => (target + 1) % 2)
48 }}
49 >
50 toggle ref target
51 </button>
52 <div ref={refTarget === 0 ? ref : null} data-testid="hover-target0">
53 target 0
54 </div>
55 <div ref={refTarget === 1 ? ref : null} data-testid="hover-target1">
56 target 1
57 </div>
58 </div>
59 )
60}
61
62Here are what happens during the actions.
63
64 useEffect sets up an effect, it gets the value of ref.current, which is null initially.
65 React renders and updates DOM, then ref.current is set with correct DOM element.
66 The callback of useEffect is run, since it reads ref.current, it gets the latest ref.current, which already points to the actual DOM, so event listeners are set up with the correct DOM.
67 First target is hovered, setIsHovered is called and re-render is scheduled.
68 In the update, ref.current points the actual DOM, useCallback sees updated deps array so its callback is run after DOM is updated. Event listeners are detached and re-attached.
69 First target is unhovered, setIsHovered is called and re-render is scheduled.
70 In the update, ref.current points to the same DOM, useEffect doesn't detect deps change so the callback is not run after DOM is updated.
71 Switch button is clicked and setRefTarget() schedules the re-render.
72 In the update, useEffect sees the ref.current pointing to the same DOM - first target. So its callback is not run after DOM is updated. Though after DOM is updated, ref.current points to 2nd target. This results in event listeners not set up on 2nd target
73
74From above analysis we can see that
75
76 useEffect dependency doesn't receive latest ref.current, because it is set after rendering is done. It actually catches the previous change of ref.current.
77 The code works for initial render because we have state updates and ref updates in the same component, so callback of useEffect happens to work because it happens after ref value is set.
78
79So our solution can easily break if it is unable to detect previous change. For example, when the ref is set asynchronously in the last test case.
80
81function App() {
82 const [ref, isHovered] = useHover()
83 const [isReady, setIsReady] = useState(false)
84 useEffect(() => {
85 setTimeout(() => setIsReady(true), 100)
86 }, [])
87 return (
88 <div ref={isReady ? ref : null}>
89 {isHovered ? 'hovered' : 'not hovered'}
90 </div>
91 )
92}
93
94In above example, when useEffect callback inside useHover is run, ref has a initial value of null. When setIsReady(true) is called and useHover is re-run, ref.current is still null, so useEffect callback inside useHover doesn't get run.
95
96Again, the core problem is that we are unable to get notified whey ref.current changes in useEffect(), our code seems to work only because most of the time, ref.current is either not changing or the state changes happens in the same component.
97
98A better approach is callback ref, because it is run when its value is set, we can get notified synchronously. Below is the skeleton code.
99
100import { Ref, useRef, useState } from 'react'
101function useHover<T extends HTMLElement>(): [Ref<T>, boolean] {
102 const [isHovered, setIsHovered] = useState(false)
103 const ref = (element) => {
104 if (element) {
105 // TODO: attach event listeners
106 } else {
107 // TODO: detach event listeners
108 }
109 }
110 return [ref, isHovered]
111}
112
113In order to detach listeners on previous element, we need to keep track of them in a useRef and below is our full code.
114
115import { Ref, useRef, useState, useCallback } from 'react'
116export function useHover<T extends HTMLElement>(): [Ref<T>, boolean] {
117 const [isHovered, setIsHovered] = useState(false)
118 const currentRef = useRef<T | null>(null)
119 const onMouseEnter = useCallback(() => setIsHovered(true), [])
120 const onMouseLeave = useCallback(() => setIsHovered(false), [])
121 const detach = useCallback(() => {
122 const current = currentRef.current
123 if (current) {
124 current.removeEventListener('mouseenter', onMouseEnter, false)
125 current.removeEventListener('mouseleave', onMouseLeave, false)
126 }
127 }, [])
128 const attach = useCallback(
129 (element: T) => {
130 detach()
131 currentRef.current = element
132 element.addEventListener('mouseenter', onMouseEnter, false)
133 element.addEventListener('mouseleave', onMouseLeave, false)
134 },
135 [detach]
136 )
137 const ref = (element: T) => {
138 if (element) {
139 attach(element)
140 } else {
141 detach()
142 }
143 }
144 return [ref, isHovered]
145}