מעבר בין "חשבונות" באפליקציית Redux

22/12/2022

אחד האתגרים בעבודה עם Redux הוא ניהול אוביקט המידע, במיוחד כשהמידע שאנחנו מנהלים לא ממש מסתדר טוב עם העקרונות של רידאקס, ובמיוחד אם אנחנו מדברים על שינוי מערכת קיימת. דוגמה? בואו. ניקח מערכת Todo פשוטה שצריכה להציג רשימת משימות, מאפשרת למשתמשים למחוק משימות ולעדכן שמשימות בוצעו. אפשר ליצור כזאת בשניה וחצי ב code sandbox עם הטמפלייט redux-toolkit-typescript. הנה קישור עם קוד לסקרנים:

https://codesandbox.io/s/eager-thompson-ijimne

1. מה אנחנו בונים

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

למה זה קשה?

  1. אוביקט ה State של היישום חייב להתאים לכל מה שאני רואה על המסך ולכל המידע שהמערכת מכירה, לכן מבנה אוביקט הסטייט צריך להכיל את כל המשתמשים ואת כל המידע שלהם. משהו כזה:
const state = {
    bob: {
        todos: [...],
    },
    mike: {
        todos: [...],
    },
    jane: {
        todos: [...],
    },
};
  1. אבל מבנה העבודה ה"רגיל" עם רידאקס לא מעודד שכפול מידע. ברידאקס קל מאוד לייצר Slice שמתאים ל todos עם כל המידע שלו והפעולות שלו, ולהפעיל את combineReducers כדי לחבר כמה סלייסים. לדוגמה:
const rootReducer = combineReducers({
  todos: todos.reducer,
});

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

const rootReducer = combineReducers({
  bob: combineReducers({ todos: todos.reducer }),
  mike: combineReducers({ todos: todos.reducer }),
  jane: combineReducers({ todos: todos.reducer }),
});
  1. ואפילו אם הצלחנו לשרוד את הסיפור של ה Reducer, הצרה האמיתית תגיע בריאקט. כל קריאה ל useSelector תצטרך להתחיל בשליפת האוביקט של המשתמש המתאים, ובכל קריאה ל dispatch ה action שלנו יישלח לכל ה reducers, כולל לאלה שמתאימים למשתמשים האחרים. בלי טיפול מתאים, כל פעולה תשפיע על כל המשתמשים שמוגדרים באתר, במקום רק על המשתמש הפעיל.

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

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

const state = {
    todos: [
        { id: 1, user: 'bob', message: 'hello world', completed: false },
    ],
};

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

3. איך Store Enhancer יכול לפתור לנו את הבעיה

כיוון אחר שאנחנו יכולים לקחת הוא לעדכן את ה Redux Store עצמו. כלומר להישאר עם אוביקט מידע שמכיל את כל הפרטים של כל המשתמשים בתור עץ:

const state = {
    bob: {
        todos: [...],
    },
    mike: {
        todos: [...],
    },
    jane: {
        todos: [...],
    },
};

אבל לעדכן את ה Store כך שיהיה יותר קל לעבוד עם העץ הזה:

  1. כל קריאה ל getState תחזיר את הסטייט שמתאים לחשבון ה"נוכחי".

  2. כל קריאה ל dispatch תוסיף ל Action שדה meta, שיכיל את שם המשתמש ה"נוכחי".

  3. לפני הכניסה ל reducer, נסתכל על שדה ה meta של ה action ונפעיל את ה reducer שנמצא בתוך הענף שמתאים לאותו משתמש (כדי לא לעדכן ענפים של משתמשים אחרים כשהם לא פעילים).

כתבתי מימוש ראשוני ומלא באגים לקונספט בקישור כאן:

https://codesandbox.io/s/heuristic-glade-7xprue?file=/src/store/store.ts

ובהטמעה:

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

const store = configureStore({
  reducer: rootReducer,
  preloadedState: {
    todos: [{ id: "ya", message: "yay", completed: false }]
  },
  enhancers: [multiUserEnhancer(["bob", "mike", "jane"])]
});

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

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

4. איך מספר Stores יכולים לפתור לנו את הבעיה

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

https://codesandbox.io/s/admiring-torvalds-e5lu0i?file=/src/index.tsx

או בהטמעה:

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