מה זה ולמה צריך Callback Ref

22/10/2022

אתם כבר יודעים להשתמש ב useEffect כדי להריץ קוד כשמשהו משתנה, ולהשתמש ב useRef כדי לתפוס אלמנט ב DOM אחרי render, אבל בשילוב בין השניים יש טעות שמאוד קל לעשות. בואו נראה קצת קוד.

1. הטעות שהכי קל לעשות עם useRef

אחד השימושים של useRef הוא בשילוב בין ספרייה חיצונית לקוד ריאקט. דמיינו למשל ספריה של נגן וידאו שמקבלת אלמנט DOM והופכת אותו לנגן, ואנחנו רוצים לעטוף אותה בקומפוננטת ריאקט שגם תיצור את אותו אלמנט DOM.

כל עוד קומפוננטת ריאקט שלכם משאירה על המסך תמיד את אלמנט ה DOM שהספריה החיצונית מקבלת אפשר להסתדר עם קוד כזה:

function MyPlayer() {
    const el = useRef(null);
    const player = useRef(null);

    useEffect(() => {
        if (el.current) {
            player.current = new VideoPlayer(el.current);
        }
    }, []);

    return (<div ref={el} /);
}

2. למה זה בעיה

הצרות יתחילו כשנצטרך להפעיל מחדש את האפקט אם האלמנט משתנה. קחו לדוגמה את הקומפוננטה הבאה:

function BuggyMeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useRef(null);
  useEffect(() => {
    const node = measuredRef.current;
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    } else {
      setHeight(-1);
    }
  }, [measuredRef.current]);

  return (
    <>
      <Child ref={measuredRef} />
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

הקומפוננטה מודדת גובה של אלמנט לפי ref שהיא יוצרת, אבל לא היא זאת שיצרה את האלמנט אלא קומפוננטת-ילד. הדרישה היא שכשהקומפוננטת ילד קיימת ומשתמשת ב ref אז יוצג הגובה האמיתי של האלמנט, אבל אם מאיזושהי סיבה הקומפוננטת ילד לא משתמשת ב ref יוצג המספר -1.

קומפוננטת Child היא:

const Child = React.forwardRef(function Child(props, ref) {
  const [visible, setVisible] = useState(true);

  function toggle() {
    setVisible((v) => !v);
  }

  return (
    <>
      {visible && <h1 ref={ref}>Hello world</h1>}
      <button onClick={toggle}>Toggle</button>
    </>
  );
});

ואתם יכולים לראות בקודסנדבוקס הבא שהקוד לא עובד: https://codesandbox.io/s/brave-shtern-b10unz?file=/src/index.js

פשוט לוחצים על כפתור Toggle ורואים שהכותרת נעלמה אבל הגובה הוא עדיין 38 פיקסלים.

הסיבה היא ששינוי בסטייט פנימי של Child לא גרם ל render מחדש של BuggyMeasureExample, ולכן האפקט לא רץ מחדש ואז לא משנה מה כתוב או לא כתוב ברשימת התלויות, כי ריאקט אפילו לא מגיע להשוות את התלויות.

3. מה עושים במקום

במקרה הכללי כלל אצבע טוב הוא לא לכתוב אף פעם ref בתוך מערך התלויות של אפקט (ואם כבר כתבתם אף פעם לא לתת את ה ref הזה למישהו אחר).

אם אתם כן צריכים קוד שירוץ כל פעם שאלמנט ב DOM מתעדכן, וכן צריכים לתת את ה ref לאלמנט הזה לקומפוננטה אחרת, תצטרכו להשתמש בטריק שנקרא Callback Ref. זה נראה ככה:

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback((node) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    } else {
      setHeight(-1);
    }
  }, []);

  return (
    <>
      <Child ref={measuredRef} />
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

עכשיו useCallback מחזירה פונקציה ולכן measureRef הוא פונקציה. אם ref הוא פונקציה אז באופן אוטומטי ריאקט מפעיל אותה כל פעם אחרי שמתבצע render ומעביר לה את ה node המעודכן. אנחנו לא צריכים כאן מערך תלויות כי זה לא אפקט שגורם להרצה מחדש בכל render אלא מנגנון של ref עם פונקציה. מערך התלויות הריק אומר לריאקט להשתמש באותה פונקציה בין render-ים, וזה אפשרי כי לפונקצייה הפנימית אין תלויות חיצוניות, מלבד setHeight שריאקט מבטיח לנו שיישאר קבוע.