• בלוג
  • ארבע טעויות נפוצות בשימוש ב useEffect

ארבע טעויות נפוצות בשימוש ב useEffect

16/10/2022

המעבר לעבודה בשיטת ה Hooks היה נשימה לרווחה עבור מתכנתי ריאקט רבים, ומקור ללחץ עבור אחרים. בכל מקרה מעבר זה דרש שינוי שיטת חשיבה והוביל לקוד ריאקט שנכתב אחרת. הפונקציה useEffect היתה כנראה ה Hook המסובך מכולם, מאחר והיא החליפה מספר פונקציות מחזור חיים שהגיעו לפניה ועדיין עבדה קצת אחרת מהשילוב של כולן. בפוסט זה אסקור 4 טעויות נפוצות בשימוש ב useEffect שאני מקווה שיעזרו לכם להשתמש בו נכון ולכתוב קומפוננטות ריאקט טובות יותר.

1. לעדכן קוד ולשכוח לעדכן את מערך התלויות

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

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

function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);
  const [height, setHeight] = useState(window.innerHeight);

  useEffect(() => {
    const handleResize = _.debounce(function handleResize() {
      setWidth(window.innerWidth);
      setHeight(window.innerHeight);
    }, 1000);
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    }
  }, []);


  return (
    <p>Window Size: {width}x{height}</p>
  );
}

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

function WindowSize(props) {
  const [width, setWidth] = useState(window.innerWidth);
  const [height, setHeight] = useState(window.innerHeight);
  const { updateMs=1000 } = props;

  useEffect(() => {
    const handleResize = _.debounce(function handleResize() {
      setWidth(window.innerWidth);
      setHeight(window.innerHeight);
    }, updateMs);
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    }
  }, []);


  return (
    <p>Window Size: {width}x{height}</p>
  );
}

אם ניצור את הקומפוננטה ונעביר לה ב props את הערך של updateMs ואז נעביר ערך אחר במקום, האפקט לא יתעדכן ותמיד ישתמש בערך הראשון שעבר.

דוגמה נוספת וקצת יותר עדינה היא הקוד הזה:

function WrongCounter() {
  const [clickCount, setClickCount] = React.useState(0)
  React.useEffect(() => {
    const timeout = setTimeout(() => {
      alert(clickCount)
    }, 5000)
    return () => clearTimeout(timeout)
  }, [])
  return (
    <div>
      <h1>Hello!</h1>
      <button onClick={() => setClickCount((prevCount) => prevCount + 1)}>
        Click me!
      </button>
    </div>
  )}

הפעם האפקט משתמש בערך של clickCount אבל אותו משתנה סטייט לא נמצא במערך התלויות. לכן אחרי שמעדכנים את הערך האפקט לא משתנה וה alert תמיד ידפיס 0.

בעוד שבדוגמה הראשונה כל מה שצריך בשביל לתקן את הקוד הוא לתקן את מערך התלויות. בדוגמה השניה הסיפור יותר עדין כי אם אני אוסיף את clickCount למערך התלויות ובאמת אצור מחדש את האפקט כל פעם שהוא ישתנה אז ההתנהגות של הקומפוננטה תשתנה - במקום להקפיץ את ה alert חמש שניות אחרי שהקומפוננטה נכנסה למסך, היא תוקפץ 5 שניות אחרי הלחיצה האחרונה על הכפתור. בשביל לשמור על ההתנהגות אני דווקא כן צריך את מערך התלויות ריק, אבל עליי לבטל את התלות ב clickCount. בשביל זה אני יכול לשמור את מספר הלחיצות במשתנה Ref:

const [clickCount, setClickCount] = React.useState(0)
const countRef = React.useRef()
React.useEffect(() => {
  const timeout = setTimeout(() => {
    alert(countRef.current)
  }, 5000)
  return () => clearTimeout(timeout)
}, [])
return (
  <div>
    <h1>Hello!</h1>
    <button
      onClick={() => {
        countRef.current = clickCount + 1
        setClickCount((prevCount) => prevCount + 1)
      }}
    >
      Click me!
    </button>
  </div>
)

(ותודה לניקולה מרגיט על הדוגמה הזאת).

חוסר תיאום בין פונקציית האפקט למערך התלויות הוא תקלה כל כך נפוצה שיש פלאגין ל eslint שמזהה אותה ומומלץ מאוד להשתמש בו בפרויקט שלכם: https://www.npmjs.com/package/eslint-plugin-react-hooks.

2. להשתמש באוביקט מקונן במערך התלויות

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

function BadRef() {
  const ref = useRef(null);
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    if (ref.current) {
        ref.current.style.backgroundColor = 'red';
    }
  }, [ref]);

  return (
    <div>
      <input type="checkbox" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
      {visible && <p ref={ref}>just some text</p>}
    </div>
  );
}

והתיקון הוא פשוט:

function BadRef() {
  const ref = useRef(null);
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    if (ref.current) {
      ref.current.style.backgroundColor = 'red';
    }
  }, [ref.current]);

  return (
    <div>
      <input type="checkbox" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
      {visible && <p ref={ref}>just some text</p>}
    </div>
  );
}

3. לשכוח להחזיר פונקציית ניקוי

טעות שלישית ברשימה היא לכתוב אפקט בלי פונקציית ניקוי. עד ריאקט 18 זה לא היה ממש טעות כי לפעמים לפי מערך התלויות היה אפשר לדעת מתי האפקט ירוץ, אבל החל מריאקט 18 אנחנו צריכים לחשוב על אפקטים בתור משהו שאפשר תמיד להסיר ולהוסיף אותו מחדש, ואסור לנו להניח שאפקט ירוץ רק פעם אחת. במצב פיתוח ריאקט אפילו מבלבל אותנו ובכוונה מריץ את כל האפקטים פעמיים כדי לוודא שהקוד שלנו לא מסתבך עם זה.

לכן קומפוננטה כזאת כבר אסור לכתוב בריאקט 18:

function WindowSize(props) {
  const [width, setWidth] = useState(window.innerWidth);
  const [height, setHeight] = useState(window.innerHeight);
  const { updateMs=1000 } = props;

  useEffect(() => {
    const handleResize = _.debounce(function handleResize() {
      setWidth(window.innerWidth);
      setHeight(window.innerHeight);
    }, updateMs);
    window.addEventListener('resize', handleResize);
  }, []);


  return (
    <p>Window Size: {width}x{height}</p>
  );
}

אפילו שמערך התלויות ריק, עדיין לא מובטח לנו שהאפקט ירוץ רק פעם אחת.

4. לשכוח מתי אפקט רץ

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

נתבונן בדוגמה הבאה:

function EvenOdd() {
  const [count, setCount] = useState(0);
  const [isEven, setIsEven] = useState(true);

  useEffect(() => {
    setIsEven(count % 2 === 0);
  }, [count]);

  return (
    <div>
      <p>{isEven ? "Even" : "Odd"}</p>
      <button onClick={() => setCount(c => c + 1)}>Clicks: {count}</button>
    </div>
  );
}

הקומפוננטה עובדת ומציגה מונה לחיצות ומעליו הטקסט - כשהמספר זוגי מופיעה המילה Even וכשאי זוגי מופיעה המילה Odd.

הבעיה כאן היא שכל לחיצה על הכפתור יוצרת מספר כפול של render-ים. במקום לעדכן את הערך של count ולרנדר את הקומפוננטה מחדש כדי להציג את הערך המעודכן, אנחנו מעדכנים קודם כל את count, מציגים אותו, ואז מעדכנים את isEven ושוב מרנדרים את הקומפוננטה.

נשים לב גם שלאפקט הזה אין פונקציית ניקוי והוא לא "מסנכרן" משתני ריאקט עם משהו שנמצא בחוץ אלא הוא פשוט אמור לסנכרן בין שני משתני סטייט באותה קומפוננטה. במקרה הזה הרבה יותר יעיל היה להשתמש במשתנה סטייט יחיד. אפילו אם חייבים להשתמש בכמה משתני סטייט, עדיף לעדכן אותם יחד באותה פונקציה עם כמה קריאות ל setState ולא דרך אפקט.

טעות דומה למרות שפחות מטרידה אפשר לראות בקוד הבא:

function TitleEditor() {
  const [title, setTitle] = useState(document.title);

  useEffect(() => {
    document.title = title;
  }, [title]);

  function handleChange(e) {
    setTitle(e.target.value);
  }

  return (
    <input type="text" value={title} onChange={handleChange} />
  );
}

זו קומפוננטה שמציגה תיבת טקסט, וכל שינוי של הטקסט בתיבה גם מעדכן את ה title של העמוד. הקוד עובד אבל השימוש באפקט כאן צורם: אין פה באמת סינכרון בין משהו חיצוני למשתנה ריאקט, אין פונקציית ניקוי לאפקט ונכון שלא יקרה נזק אם נריץ את האפקט כמה פעמים אבל בתכל'ס כל הרצה נוספת של האפקט כשה title כבר מתאים למשתנה הסטייט היא פשוט מיותרת.

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

function TitleEditor() {
  const [title, setTitle] = useState(document.title);

  function handleChange(e) {
    setTitle(e.target.value);
    document.title = e.target.value;
  }

  return (
    <input type="text" value={title} onChange={handleChange} />
  );
}

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