Jak poprawnie zareagować na zamontowanie elementu w React

Jeśli chcesz jakoś zareagować na montowanie (ang. mounting) elementu Reacta w DOM, to może Cię kusić, aby korzystać z useRef
w celu zdobycia referencji oraz z useEffect
do odpowiadania na montowanie i odmontowywanie (ang. unmounting). Jednak to nie zadziała. To dlatego, że w momencie zamontowania komponentu (lub odmontowania), w którym do ref
przypiszemy wynik useRef
, nie zostanie uruchomiony callback i nie będzie ponownego renderowania.
Dostalibyście nawet ostrzeżenie wynikające z zasad ESLint react-hooks. Ani podanie ref
, ani ref.current
jako zależności w useEffect
nie uruchomią wywołania zwrotnego.
react-hooks ostrzega przed używaniem useRef razem z useEffect
Sami się przekonajcie:
import React, { useEffect, useRef, useState } from "react";
import "./styles.css";
import catImageUrl from "./cat.png";
/*
for more info see:
https://medium.com/welldone-software/usecallback-might-be-what-you-meant-by-useref-useeffect-773bc0278ae
*/
export default function App() {
const [count, setCount] = useState(1);
const shouldShowImageOfCat = count % 3 === 0;
const [catInfo, setCatInfo] = useState(false);
// notice how none of the deps of useEffect
// manages to trigger the hook in time
const catImageRef = useRef();
useEffect(() => {
console.log(catImageRef.current);
setCatInfo(catImageRef.current?.getBoundingClientRect());
// notice the warning below
}, [catImageRef, catImageRef.current]);
return (
<div className="App">
<h1>useEffect & useRef vs useCallback</h1>
<p>
An image of a cat would appear on every 3rd render.
<br />
<br />
Would our hook be able to make the emoji see it?
<br />
<br />
{catInfo ? "?" : "?"} - I {catInfo ? "" : "don't"} see the cat ?
{catInfo ? `, it's height is ${catInfo.height}` : ""}!
</p>
<input disabled value={`render #${count}`} />
<button onClick={() => setCount((c) => c + 1)}>next render</button>
<br />
{shouldShowImageOfCat ? (
<img
ref={catImageRef}
src={catImageUrl}
alt="cat"
width="50%"
style={{ padding: 10 }}
/>
) : (
""
)}
</div>
);
}
Powyżej kod, w którym useRef
jako zależność useEffect
nie jest w stanie uruchomić go na czas. Oto link do Sandbox.
Co więc możemy zrobić?
useCallback
Oto link do fragmentu oficjalnej dokumentacji Reacta, który traktuje o useCallback
Możemy tutaj polegać na przekazaniu zwykłej funkcji opakowanej w useCallback
do ref
i zareagować na ostatnią referencję do węzła w DOM, który zostanie zwrócony.
Spróbujcie sami:
import React, { useCallback, useState } from "react";
import "./styles.css";
import catImageUrl from "./cat.png";
/*
for more info see:
https://medium.com/welldone-software/usecallback-might-be-what-you-meant-by-useref-useeffect-773bc0278ae
*/
export default function App() {
const [count, setCount] = useState(1);
const shouldShowImageOfCat = count % 3 === 0;
const [catInfo, setCatInfo] = useState(false);
// notice how this is a useCallback
// that's used as the "ref" of the image below
const catImageRef = useCallback((catImageNode) => {
console.log(catImageNode);
setCatInfo(catImageNode?.getBoundingClientRect());
}, []);
return (
<div className="App">
<h1>useEffect & useRef vs useCallback</h1>
<p>
An image of a cat would appear on every 3rd render.
<br />
<br />
Would our hook be able to make the emoji see it?
<br />
<br />
{catInfo ? "?" : "?"} - I {catInfo ? "" : "don't"} see the cat ?
{catInfo ? `, it's height is ${catInfo.height}` : ""}!
</p>
<input disabled value={`render #${count}`} />
<button onClick={() => setCount((c) => c + 1)}>next render</button>
<br />
{shouldShowImageOfCat ? (
<img
ref={catImageRef}
src={catImageUrl}
alt="cat"
width="50%"
style={{ padding: 10 }}
/>
) : (
""
)}
</div>
);
}
Kod, w którym useCallback
zostało użyte jako ref
i wykrywa rendering na czas. Oto link do Sandbox.
Zastrzeżenie: funkcja z ref
na pewno zostanie wywołana przy montowaniu i odmontowywaniu elementów, nawet przy pierwszym montowaniu i nawet jeśli odmontowanie jest rezultatem odłączania elementu nadrzędnego.
Zastrzeżenie 2: upewnijcie się, że opakowaliście wywołania zwrotne ref w useCallback
.
Jeśli bez powyższego wywołamy render
z wywołania zwrotnego ref
, to zostanie ono uruchomione ponownie z null
, doprowadzając do nieskończonej pętli spowodowanej tym, jak React działa w środku.
Możecie z tym poeksperymentować. Spróbujcie usunąć useCallback z kodu i zobaczcie, jak wygląda to tutaj.
Wzorca tego można użyć na wiele sposobów:
useState
Ponieważ useState
to funkcja, która nie zmienia się między renderami, to można jej użyć jako ref
. W tym przypadku cały node zostanie zachowany w swoim własnym stanie.
Kiedy stan ulega zmianie, to uruchamia re-render, co sprawia, że można go używać w wynikach renderowania oraz jako zależność useEffect
:
const [node, setRef] = useState(null);
useEffect(() => {
if (!node) {
console.log('unmounted!');
return null;
}
console.log('mounted');
const fn = e => console.log(e);
node.addEventListener('mousedown', fn);
return () => node.removeEventListener('mousedown', fn);
}, [node])
// <div ref={setRef}....
useStateRef
Dostęp do DOM jest kosztowny, więc celujemy w to, aby nie robić tego za często. Jeśli nie potrzebujesz całego node’a tak jak w poprzednim hooku, to najlepiej będzie, jak zapiszesz w stanie jedynie jego część:
// the hook
function useStateRef(processNode) {
const [node, setNode] = useState(null);
const setRef = useCallback(newNode => {
setNode(processNode(newNode));
}, [processNode]);
return [node, setRef];
}
// how it's used
const [clientHeight, setRef] = useStateRef(node => (node?.clientHeight || 0));
useEffect(() => {
console.log(`the new clientHeight is: ${clientHeight}`);
}, [clientHeight])
// <div ref={setRef}....
// <div>the current height is: {clientHeight}</div>
Jak widać dostęp do DOM uzyskujemy tylko wtedy, kiedy element, do którego przekazujemy ref został zamontowany, a w stanie mamy jedynie clientHeight.
useRefWithCallback
Czasami jednak można obejść się bez uruchamiania re-renderów przy montowaniu i odmontowywaniu elementów, w których używamy ref
.
Następujący hook nie zapisuje węzła w stanie. Zamiast wykorzystywać stan, hook bezpośrednio odpowiada na montowanie i odmontowywanie, tak aby nie uruchamiać żadnych re-renderów.
// the hook
function useRefWithCallback(onMount, onUnmount) {
const nodeRef = useRef(null);
const setRef = useCallback(node => {
if (nodeRef.current) {
onUnmount(nodeRef.current);
}
nodeRef.current = node;
if (nodeRef.current) {
onMount(nodeRef.current);
}
}, [onMount, onUnmount]);
return setRef;
}
const onMouseDown = useCallback(e => console.log('hi!', e.target.clientHeight), []);
const setDivRef = useRefWithCallback(
node => node.addEventListener("mousedown", onMouseDown),
node => node.removeEventListener("mousedown", onMouseDown)
);
// <div ref={setDivRef}
W końcu uda Wam się zrozumieć zasadę korzystania z useCallback
jako ref w elemencie. Myślę, że będziecie mieć też tutaj jakieś swoje własne pomysły.
Oryginał tekstu w języku angielskim możesz przeczytać tutaj.