Sytuacja kobiet w IT w 2024 roku
11.03.20215 min
Vitali Zaidman

Vitali ZaidmanSoftware ArchitectWelldone Software

Jak poprawnie zareagować na zamontowanie elementu w React

Sprawdź, jakie jest najlepsze rozwiązanie, jeśli chodzi o reagowanie na elementy Reacta, które jest niekoniecznie zgodne z intuicją.

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

<p>Loading...</p>