← All React exercises

React implementation · 06

UseHover()

StatusNot taken

Problem

It is common to see conditional rendering based on hover state of some element. We can achive it by CSS pseduo class :hover, but for more complex cases it might be better to have state controlled by script. Now you are asked to create a useHover() hook. function App() { const [ref, isHovered] = useHover() return <div ref={ref}>{isHovered ? 'hovered' : 'not hovered'}</div> }

Solution

Review after your attempt
solution.tsx
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}