← All React exercises

React implementation · 11

UseFocus()

StatusNot taken

Problem

CSS pseudo-class :focus-within could be used to allow conditional rendering in parent element on the focus state of descendant elements. While it is cool, in complex web apps, it might be better to control the state in script. Now please create useFocus() to support this. function App() { const [ref, isFocused] = useFocus() return <div> <input ref={ref}/> {isFocused && <p>focused</p>} </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 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}