• בלוג
  • עדכונים אופטימיים ב Redux Toolkit Query

עדכונים אופטימיים ב Redux Toolkit Query

13/11/2022

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

ל RTK Query יש דרך מובנית ומאוד נוחה לממש עדכונים אופטימיים בזכות שילוב של מספר מנגנונים של הספריה:

  1. ספריית RTK Query מחזיקה את כל התוצאות של כל השאילתות בזיכרון, ומאפשרת לנו לעדכן את השאילתות השמורות.

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

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

ככה זה נראה בקוד:

    createNote: builder.mutation({
      query: (noteText) => ({
        url: `/notes`,
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ text: noteText }),
      }),      
      async onQueryStarted(noteText, { dispatch, queryFulfilled }) {
        let newNoteId = nanoid();

        const patchResult = dispatch(
          notesApi.util.updateQueryData('getNotes', undefined, (draft) => {
            return [...draft, { text: noteText, id: newNoteId }];
          }));
        try {
          const data = await queryFulfilled
          dispatch(
            notesApi.util.updateQueryData('getNotes', undefined, (draft) => {        
              return draft.map(d => d.id === newNoteId ? data.data : d )
            }));
       } catch (err) {
        console.log(`error`);
        patchResult.undo();
       }
      }
    })

הקוד מגדיר פעולה חדשה בשם createNote, עבור אפליקציה ששומרת רשימה של פתקים. בשביל ההגדרה אני מפעיל את builder.mutation ומעביר לו אוביקט עם שני מפתחות:

  1. המפתח query יוצר את קוד בקשת ה POST שאני שולח לשרת כדי ליצור פתק חדש. לפתק חדש יש בסך הכל את הטקסט בפתק.

  2. המפתח onQueryStarted מחזיק את הפונקציה שאחראית על העדכונים האופטימיים.

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

const patchResult = dispatch(
  notesApi.util.updateQueryData('getNotes', undefined, (draft) => {
    return [...draft, { text: noteText, id: newNoteId }];
}));

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

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

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

const data = await queryFulfilled

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

dispatch(
  notesApi.util.updateQueryData('getNotes', undefined, (draft) => {
    return draft.map(d => d.id === newNoteId ? data.data : d )
  }));

ואם היתה שגיאה והשרת לא הצליח לשמור את הפתק? בשביל זה יש לי בלוק catch:

} catch (err) {
 patchResult.undo();
}

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

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

} catch (err) {
 dispatch(
   notesApi.util.updateQueryData('getNotes', undefined, (draft) => {
     return draft.filter(note => note.id !== newNoteId);
   }));
  }

קוד כזה מאפשר ל undo להיות יותר ספציפי וממש למחוק את הפתק החדש שניסינו להכניס במקום להחזיר את המצב למה שהיה כשיצרנו את ה patch.