1We are asked to create a ref, let's start with useRef().
2
3import { Ref, useRef, useState } from "react";
4export function useFocus<T extends HTMLElement>(): [Ref<T>, boolean] {
5 const ref = useRef<T | null>(null);
6 const [isFocused] = useState(false);
7 return [ref, isFocused];
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 useFocus<T extends HTMLElement>(): [Ref<T>, boolean] {
14 const ref = useRef<T | null>(null);
15 const [isFocused, setIsFocused] = useState(false);
16 useEffect(() => {
17 const element = ref.current;
18 if (element) {
19 const onFocus = () => setIsFocused(true);
20 const onBlur = () => setIsFocused(false);
21 element.addEventListener("focus", onFocus, false);
22 element.addEventListener("blur", onBlur, false);
23 return () => {
24 element.removeEventListener("focus", onFocus, false);
25 element.removeEventListener("blur", onBlur, false);
26 };
27 }
28 }, [ref.current]);
29 return [ref, isFocused];
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, isFocused] = useFocus<HTMLInputElement>();
40 const [refTarget, setRefTarget] = useState<number>(0);
41 return (
42 <div>
43 <p>{isFocused ? "focused" : "not focused"}</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 <input ref={refTarget === 0 ? ref : null} data-testid="focus-target0" />
53 <input ref={refTarget === 1 ? ref : null} data-testid="focus-target1" />
54 </div>
55 );
56}
57
58Here are what happens during the actions.
59
60 useEffect sets up an effect, it gets the value of ref.current, which is null initially.
61 React renders and updates DOM, then ref.current is set with correct DOM element.
62 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.
63 First target is focused, setIsFocused is called and re-render is scheduled.
64 In the update, ref.current points the actual DOM, useEffect sees updated deps array so its callback is run after DOM is updated. Event listeners are detached and re-attached.
65 First target is blurred, setIsFocused is called and re-render is scheduled.
66 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.
67 Switch button is clicked and setRefTarget() schedules the re-render.
68 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.
69
70From above analysis we can see that:
71
72 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.
73 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.
74
75So 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.
76
77function App() {
78 const [ref, isFocused] = useFocus();
79 const [isReady, setIsReady] = useState(false);
80 useEffect(() => {
81 setTimeout(() => setIsReady(true), 100);
82 }, []);
83 return (
84 <div ref={isReady ? ref : null}>
85 {setIsFocused ? "focused" : "not focused"}
86 </div>
87 );
88}
89
90In above example, when useEffect callback inside useFocus is run, ref has a initial value of null. When setIsReady(true) is called and useFocus is re-run, ref.current is still null, so useEffect callback inside useFocus doesn't get run.
91
92Again, 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.
93
94A better approach is callback ref, because it is run when its value is set, we can get notified synchronously. Below is the skeleton code.
95
96import { Ref, useRef, useState } from "react";
97function useFocus<T extends HTMLElement>(): [Ref<T>, boolean] {
98 const [isFocused, setIsFocused] = useState(false);
99 const ref = (element) => {
100 if (element) {
101 // TODO: attach event listeners
102 } else {
103 // TODO: detach event listeners
104 }
105 };
106 return [ref, isFocused];
107}
108
109In order to detach listeners on previous element, we need to keep track of them in a useRef and below is our full code.
110
111import { Ref, useRef, useState, useCallback } from "react";
112export function useFocus<T extends HTMLElement>(): [Ref<T>, boolean] {
113 const [isFocused, setIsFocused] = useState(false);
114 const currentRef = useRef<T | null>(null);
115 const onFocus = useCallback(() => setIsFocused(true), []);
116 const onBlur = useCallback(() => setIsFocused(false), []);
117 const detach = useCallback(() => {
118 const current = currentRef.current;
119 if (current) {
120 current.removeEventListener("focus", onFocus, false);
121 current.removeEventListener("blur", onBlur, false);
122 }
123 }, []);
124 const attach = useCallback(
125 (element: T) => {
126 detach();
127 currentRef.current = element;
128 element.addEventListener("focus", onFocus, false);
129 element.addEventListener("blur", onBlur, false);
130 },
131 [detach]
132 );
133 const ref = (element: T) => {
134 if (element) {
135 attach(element);
136 } else {
137 detach();
138 }
139 };
140 return [ref, isFocused];
141}