לא נשמע כמו עוד משתנה State

10/06/2023

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

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

1. שלושה מונים ועוד סטייט

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

function Counter({onClick, id}) {
    const [count, setCount] = useState(0)
    const handleClick = () => {
      onClick();
      setCount((count) => count + 1);
    }

    return (
      <p>
        ({id}) count is {count}
        <button onClick={handleClick}>+</button>
      </p>
    )
}

function App() {
  const [count, setCount] = useState(0);
  const inc = () => setCount(c => c + 1);

  return (
    <>
      <p>Total = {count}</p>
      <Counter id="1" onClick={inc} />
      <Counter id="2" onClick={inc} />
      <Counter id="3" onClick={inc} />
    </>
  )
}

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

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

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

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

2. הסכום הוא לא סטייט נפרד

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

function Counter({id, count, set}) {    
    return (
      <p>
        ({id}) count is {count}
        <button onClick={() => set(count + 1)}>+</button>
      </p>
    )
}

function App() {
  const [count, setCount] = useState([0, 0, 0]);
  const setValue = (where, newValue) => setCount(count.map((existingValue, index) => index === where ? newValue : existingValue));
  const sum = count.reduce((a, b) => a + b);
  return (
    <>
      <p>Total = {sum}</p>
      <Counter id="1" count={count[0]} set={setValue.bind(null, 0)} />
      <Counter id="2" count={count[1]} set={setValue.bind(null, 1)} />
      <Counter id="3" count={count[2]} set={setValue.bind(null, 2)} />
    </>
  )
}

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

3. שיפור אסתטי עם useReducer ו useContext

במצב כזה שאנחנו יודעים מה מבנה הסטייט שאנחנו צריכים אבל לא מרוצים מהעברת המשתנים בין הקומפוננטות נוכל להיעזר ב useContext ו useReducer וב Custom Hooks כדי לארגן מחדש את הקוד שיראה יותר דומה לגירסה הראשונה.

אני מתחיל בהגדרת פונקציה בשם reducer שאחראית על הפעולות שקומפוננטות ה Counter ירצו לבצע על הסטייט שלהם (שמוגדר ב App):

function counterReducer(counterGroup, action) {
  const { id, newValue } = action;
  return {
    ...counterGroup,
    [id]: typeof newValue === 'function' ? newValue(counterGroup[id] || 0) : newValue,
  }
}

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

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

const CounterContext = createContext({});

ומסיים עם Custom Hook שיוצא את אוביקט המונים מהקונטקסט הגלובאלי ויחזיר שני ערכים - הערך הנוכחי של המונה ופונקציית שינוי, בדיוק כמו ש useState עשתה בגירסה הראשונה של הקוד:

function useCounter(id) {
  const {counters, dispatch} = useContext(CounterContext);
  return [counters[id] || 0, (newValue) => dispatch({id, newValue})];
}

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

function Counter({id}) {
  const [count, setCount] = useCounter(id);
  return (
    <p>
      ({id}) count is {count}
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </p>
  )
}

function App() {  
  const [counters, dispatch] = useReducer(counterReducer, {});

  const sum = Object.values(counters).reduce((a, b) => a + b, 0);

  return (
    <CounterContext.Provider value={{counters, dispatch}}>
      <p>Total = {sum}</p>
      <Counter id="1" />
      <Counter id="2" />
      <Counter id="3" />
    </CounterContext.Provider>
  )
}

הפעם Counter נראית כמעט כמו בדוגמה הראשונה: נכון במקום useState היא קוראת ל Custom Hook שיצרתי, אבל חוץ מזה הכל אותו דבר.

גם הקומפוננטה App נשארה פשוטה. היא כבר לא צריכה לשבור את הראש איך לחלק את הסטייט בין הילדים שלה כי כל הקוד של זה עבר ל Custom Hook ול Reducer. מה שנשאר לה זה לחשב את הסכום מתוך אוביקט המונים ששמור אצלה בסטייט ולעטוף את כל המונים ב Provider המתאים.