[ריאקט] מתי Suspense שווה את המאמץ

09/02/2023

אחד הפיצ'רים שעוררו התלהבות בריאקט 18 נקרא Suspense For Data Fetching. בפוסט זה נראה מהו Suspense For Data Fetching ומתי כדאי או לא כדאי להשתמש בו.

1. הצורך ב Suspense

לפני Suspense, כשרצינו (או עדיין רוצים) להציג קומפוננטה שתלויה במידע שמגיע מהשרת היו לנו שתי אפשרויות:

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

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

בדוגמה פשוטה מ swr, הקומפוננטה הבאה מושכת מידע על משתמש:

import useSWR from 'swr'

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher)

  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

הקומפוננטה אחראית לייצר 3 ייצוגים - אם הבקשה נכשלה היא מחזירה את הטקסט failed to load, אם הבקשה הצליחה היא מחזירה את שם המשתמש ואם הבקשה בטעינה היא מחזירה את הטקסט loading.

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

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

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

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

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

במצב כזה כל קומפוננטה תצטרך למשוך את המידע שרלוונטי עבורה. אם כל קומפוננטה תציג הודעת "המידע בטעינה" יהיה לנו דף שמורכב מכמה Spinner-ים שאולי לא מתואמים ביניהם.

2. איך Suspense עובד

אני מתבסס על הדוגמה מהתיעוד שזמינה בקודסנדבוקס הזה: https://codesandbox.io/s/s9zlw3

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

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}>
        <Albums artistId={artist.id} />
      </Suspense>
    </>
  );
}

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

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

export default function Albums({ artistId }) {
  const albums = use(fetchData(`/${artistId}/albums`));
  return (
    <ul>
      {albums.map(album => (
        <li key={album.id}>
          {album.title} ({album.year})
        </li>
      ))}
    </ul>
  );
}

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

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

function use(promise) {
  if (promise.status === 'fulfilled') {
    return promise.value;
  } else if (promise.status === 'rejected') {
    throw promise.reason;
  } else if (promise.status === 'pending') {
    throw promise;
  } else {
    promise.status = 'pending';
    promise.then(
      result => {
        promise.status = 'fulfilled';
        promise.value = result;
      },
      reason => {
        promise.status = 'rejected';
        promise.reason = reason;
      },
    );
    throw promise;
  }
}

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

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

3. מפל בקשות

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

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

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

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

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

4. מה אפשר לעשות במקום Suspense?

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

export default function ArtistPage({ artist }) {
  const { data, isLoading } = useSWR(`/${artist.id}/albums`);
  return (
    <>
      <h1>{artist.name}</h1>
      {isLoading ? <Loading /> : <Albums data={albums} />}
    </>
  );
}

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

5. אז לשכתב?

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

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

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