• בלוג
  • איך לירות לעצמך ברגל בריאקט

איך לירות לעצמך ברגל בריאקט

28/02/2022

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

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

export default function App() {
  const [text, setText] = React.useState("");
  const [resetCount, setResetCount] = React.useState(0);

  function handleChange(e) {
    setText(e.target.value);
    if (e.target.value === "") {
      setResetCount(resetCount + 1);
    }
  }

  function reset() {
    setText("");
    setResetCount(resetCount + 1);
  }

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <div>
        <p>reset count = {resetCount}</p>
        <input type="text" value={text} onChange={handleChange} />
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
}

שימו לב לקוד הכפול בשתי הפונקציות handleChange ו reset. יש שתי דרכים קלות לכתוב אותו בצורה יותר גנרית:

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

  2. אפשר להשתמש ב useEffect שיבדוק אם הטקסט התרוקן ויעדכן את המונה.

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

export default function App() {
  const [text, _setText] = React.useState("");
  const [resetCount, setResetCount] = React.useState(0);

  function setText(newText) {
    _setText(newText);
    if (text !== "" && newText === "") {
      setResetCount(resetCount + 1);
    }
  }

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

  function reset() {
    setText("");
  }

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <div>
        <p>reset count = {resetCount}</p>
        <input type="text" value={text} onChange={handleChange} />
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
}

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

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

  function deleteLastChar() {
    setText(text.slice(0, -1));
  }

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <div>
        <p>reset count = {resetCount}</p>
        <input type="text" value={text} onChange={handleChange} />
        <button onClick={reset}>Reset</button>
        <button onClick={deleteLastChar}>Delete Last Char</button>
      </div>
    </div>
  );

ולמה צעד לכיוון האבדון? כי הקוד עדיין עובד אבל המוקשים רק מתרבים. אם עכשיו אני רוצה להוסיף כפתור למחיקת שני תווים - שכמובן ישתמש בפונקציה שכבר יש לי - אז הקוד יפסיק לעבוד:

export default function App() {
  const [text, _setText] = React.useState("");
  const [resetCount, setResetCount] = React.useState(0);

  function setText(newText) {
    _setText(newText);
    if (text !== "" && newText === "") {
      setResetCount(resetCount + 1);
    }
  }

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

  function reset() {
    setText("");
  }

  function deleteLastChar() {
    setText(text.slice(0, -1));
  }

  function deleteTwoChars() {
    deleteLastChar();
    deleteLastChar();
  }

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <div>
        <p>reset count = {resetCount}</p>
        <input type="text" value={text} onChange={handleChange} />
        <button onClick={reset}>Reset</button>
        <button onClick={deleteLastChar}>Delete Last Char</button>
        <button onClick={deleteTwoChars}>Delete Two Chars</button>
      </div>
    </div>
  );
}

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

export default function App() {
  const [text, setText] = React.useState("");
  const [resetCount, setResetCount] = React.useState(0);

  React.useEffect(() => {
    if (text === "") {
      setResetCount((c) => c + 1);
    }
  }, [text]);

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

  function reset() {
    setText("");
  }

  function deleteLastChar() {
    setText((t) => t.slice(0, -1));
  }

  function deleteTwoChars() {
    deleteLastChar();
    deleteLastChar();
  }

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <div>
        <p>reset count = {resetCount}</p>
        <input type="text" value={text} onChange={handleChange} />
        <button onClick={reset}>Reset</button>
        <button onClick={deleteLastChar}>Delete Last Char</button>
        <button onClick={deleteTwoChars}>Delete Two Chars</button>
      </div>
    </div>
  );
}

ולמה כמעט? כי עכשיו אנחנו מקבלים הפעלה אחת מיותרת של האפקט בפעם הראשונה שהקומפוננטה מופיעה על המסך, כלומר הערך של resetCount מתחיל מאחד. הדרך המקובלת להתעלם מהאפקט הראשון היא להוסיף משתנה ref:

export default function App() {
  const [text, setText] = React.useState("");
  const [resetCount, setResetCount] = React.useState(0);
  const isFirstEffect = React.useRef(true);

  React.useEffect(() => {
    // skip the first time
    if (isFirstEffect.current) {
      isFirstEffect.current = false;
      return;
    }

    if (text === "") {
      setResetCount((c) => c + 1);
    }
  }, [text]);

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

  function reset() {
    setText("");
  }

  function deleteLastChar() {
    setText((t) => t.slice(0, -1));
  }

  function deleteTwoChars() {
    deleteLastChar();
    deleteLastChar();
  }

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <div>
        <p>reset count = {resetCount}</p>
        <input type="text" value={text} onChange={handleChange} />
        <button onClick={reset}>Reset</button>
        <button onClick={deleteLastChar}>Delete Last Char</button>
        <button onClick={deleteTwoChars}>Delete Two Chars</button>
      </div>
    </div>
  );
}

מוזמנים לראות את הגירסה האחרונה עובדת בקודסנדבוקס בקישור הבא: https://codesandbox.io/s/affectionate-lamport-ddrc3y?file=/src/App.js.