כמה טיפים לגבי פיצול Reducer ב Redux

16/08/2020

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

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

For any meaningful application, putting all your update logic into a single reducer function is quickly going to become unmaintainable.

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

אבל הניואנס במשפט שקל לפספס אותו הוא שיש יותר מדרך אחת לפצל Reducer. השיטה הפופולרית של combineReducers (שנקראת בתיעוד Slice Reducer) היא רק אחת מהן.

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

1. למה בעצם צריך לפצל את ה Reducer

הקוד הבא מגדיר Reducer ליישום ניהול משימות ולקוח מתוך התיעוד של רידאקס:

const todos = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ]
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    default:
      return state
  }
}

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

הבחירה בפונקציה אחת היא אילוץ של Redux. הפונקציה createStore שיוצרת את מחסן המידע והכרחית לעבודה עם Redux מקבלת כפרמטר רק פונקציית Reducer אחת. ולכן ברור שפיצול ה Reducer הראשי (נקרא Root Reducer בתיעוד) לחלקים הוא בלתי נמנע.

2. שיטה 1 - יצירת פונקציות עזר

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

בדוגמא שראינו Refactoring ברוח זו יראה כך:

const todos = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return addTodo(state, action);

    case 'TOGGLE_TODO':
      return toggleTodo(state, action);

    default:
      return state;
  }
}

function addTodo(state, action) {
  return [
    ...state,
    {
      id: action.id,
      text: action.text,
      completed: false
    }
  ]
}

function toggleTodo(state, action) {
  return state.map(todo =>
    todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
  )
}

export default todos

בפוסט צמצום קוד Boilerplate ב Redux הצעתי גם דרך לבנות פונקציית Reducer ראשית דינמית שמפעילה את פונקציית העזר המתאימה לפי שם ה Action, וכך לחסוך את בלוק ה switch/case שמופיע כאן.

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

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

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

3. שיטה 2 - Slice Reducer

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

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

במצב כזה ה Reducer מהדוגמא עם תוספת הדיאלוג המודאלי עשוי להיראות כך:

const initialState = {
  tasks: [],
  modalDialog: null,
};

const todos = (state = initialState, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return addTodo(state, action);

    case 'TOGGLE_TODO':
      return toggleTodo(state, action);

    case 'SHOW_MODAL_DIALOG':
      return showModalDialog(state, action);

    case 'HIDE_MODAL_DIALOG':
      return hideModalDialog(state, action);

    default:
      return state;
  }
}

function showModalDialog(state, action) {
  return {
    ...state,
    modalDialog: { header: action.header, text: action.text },
  },
}

function hideModalDialog(state, action) {
  return {
    ...state,
    modalDialog: null,
  }
}


function addTodo(state, action) {
  return {
    ...state,
    tasks: [
      ...state.tasks,
      {
        id: action.id,
        text: action.text,
        completed: false
      }
    ],
  },
}

function toggleTodo(state, action) {
  return {
    ...state,
    tasks: state.tasks.map(todo => 
      todo.id === action.id ? { ...todo, completed: !todo.completed } : todo)
  }
}

export default todos

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

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

בקוד הדוגמא שכתבתי קודם תמיד שם השדה שמתעסק בדיאלוג המודאלי הוא modalDialog. ארגון מחדש של הקוד יכול לאפשר לי מתוך ה Reducer לקבוע את שם השדה שעליו "יישמר" המידע שקשור לדיאלוג המודאלי:

const initialState = {
  tasks: [],
  modalDialog: null,
};

const todos = (state = initialState, action) => {
  state.modalDialog = reduceModalDialog(state.modalDialog, action);

  switch (action.type) {
    case 'ADD_TODO':
      return addTodo(state, action);

    case 'TOGGLE_TODO':
      return toggleTodo(state, action);

    default:
      return state;
  }
}

function reduceModalDialog(state, action) {
  switch(action.type) {
    case 'SHOW_MODAL_DIALOG':
      return showModalDialog(state, action);

    case 'HIDE_MODAL_DIALOG':
      return hideModalDialog(state, action);

    default:
      return state;
  }
}


function showModalDialog(state, action) {
  return { header: action.header, text: action.text },
}

function hideModalDialog(state, action) {
  return null;
}


function addTodo(state, action) {
  return {
    ...state,
    tasks: [
      ...state.tasks,
      {
        id: action.id,
        text: action.text,
        completed: false
      }
    ],
  },
}

function toggleTodo(state, action) {
  return {
    ...state,
    tasks: state.tasks.map(todo => 
      todo.id === action.id ? { ...todo, completed: !todo.completed } : todo)
  }
}

export default todos

שימו לב לשורה הראשונה בפונקציה todos: היא קוראת לפונקציית עזר על שדה מסוים באוביקט המידע. פונקציית העזר נקראת Slice Reducer כי היא מטפלת בפרוסה מסוימת מאוביקט המידע ולא בכולו. בגירסא הקודמת הפונקציות showModalDialog ו clearModalDialog היו צריכות ממש "לדעת" את השם של השדה באוביקט המידע בו שמור המידע שקשור לדיאלוג המודאלי. עכשיו בגירסא החדשה אפשר לקחת להעביר אותן לפרויקט אחר שם הדיאלוג המודאלי יישמר בשדה אחר ב State והכל יהיה בסדר כי ה Reducer ששם יעביר את השדה המתאים.

אגב, הפונקציה combineReducers של Redux פשוט יוצרת Slice Reducers.

4. שיטה 3 - Higher Order Reducer

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

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

const todos = (state = [], action) => {
  console.log('Got action: ', action);
  console.log('State before: ', JSON.stringify(state));

  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ]
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    default:
      return state
  }
}

export default todos

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

בשביל Code Reuse מסוג זה אנחנו יכולים לכתוב Higher Order Reducer. זה בסך הכל פונקציה שמקבלת Reducer ומחזירה Reducer חדש עם ההתנהגות החדשה.

בדוגמת הקוד של ה Todos והכתיבה ל console.log אפשר לכתוב:

function debuggable(originalReducer) {
  return function reducer(state, action) {
    console.log('Got action: ', action);
    console.log('State before: ', JSON.stringify(state));
    const newState = originalReducer(state, action);
    console.log('State after: ', JSON.stringify(newState));
    return newState;
  }
}

const todos = debuggable((state = [], action) => {

  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ]
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    default:
      return state
  }
});

export default todos

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

5. לסיכום: מתי להשתמש בכל שיטה

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

כשאנחנו מזהים שיש לנו לוגיקה מסוימת ומידע מסוים שקשור אליה, שמייצרים מבנה סגור שאפשר להשתמש בו במספר פרויקטים - אפשר להשתמש ב Slice Reducer כדי להוציא החוצה את כל המבנה, וכך בכל פרויקט שנצטרך אותו נוכל "להדביק" את המבנה הזה בשם אחר. זה בדיוק מה שעושה הספריה redux-modal.

כשאנחנו מזהים שיש לנו לוגיקה מסוימת שמשפיעה על כל אוביקט המידע ושאנחנו רוצים לשתף בין מספר פרויקטים נוכל לכתוב Higher Order Reducer. ההבדל המרכזי בין Higher Order Reducer לבין Middleware הוא ש HOR יכול להחזיר את הסטייט החדש ולכן מתאים יותר להתנהגויות שמשנות סטייט, בעוד ש Middleware יכול לשנות Actions ולנהל Flow של אירועים ולכן מתאים יותר לטיפול בקוד אסינכרוני או לכל מה שקשור ל Actions. הספריה redux-undo משתמשת במנגנון זה כדי לטפל בפעולה של Undo ולהחליף את כל הסטייט מתוך ה Reducer הראשי. הפונקציה combineReducers של redux משתמשת במנגנון זה כדי לבנות Reducer חדש מתוך מספר Slice Reducers.