طراحی Fallback UI برای Suspense

در فصل هفدهم، طراحی Fallback UI را برای Error Boundary دیدیم: یک پیام متناسب با اهمیت هر بخش، به‌جای یک جمله ثابت و یکسان. در این درس، همان سؤال را این‌بار برای Suspense می‌پرسیم: fallback یک Suspense، باید چه شکلی باشد تا تجربه بارگذاری، به‌جای آزاردهنده بودن، طبیعی به نظر برسد؟

مشکل یک Spinner ساده و یکسان در همه‌جا

ساده‌ترین fallback، یک متن یا یک چرخان (Spinner) کوچک وسط صفحه است. این گزینه برای یک بارگذاری کوتاه و کلی صفحه قابل قبول است، اما وقتی همین الگو برای هر بخش کوچک (مثل یک کارت در یک لیست) تکرار شود، صفحه در حین بارگذاری، پر از نقطه‌های پراکنده و بی‌ربط می‌شود که هیچ شباهتی به محتوای نهایی ندارند.

راه‌حل رایج: Skeleton UI

یک الگوی بسیار رایج‌تر، Skeleton UI است: یک نسخه خاکستری و ساده‌شده از همان چیدمان نهایی، به‌جای یک Spinner بی‌شکل:

jsx
function ProfileSkeleton() {
  return (
    <div className="skeleton">
      <div className="skeleton-avatar" />
      <div className="skeleton-line" />
      <div className="skeleton-line short" />
    </div>
  );
}

<Suspense fallback={<ProfileSkeleton />}>
  <ProfileSection />
</Suspense>

چون ابعاد و چیدمان Skeleton، تقریباً همان ابعاد محتوای نهایی است، وقتی داده واقعی می‌رسد و جایگزین می‌شود، صفحه پرش ناگهانی (Layout Shift) نمی‌خورد؛ کاربر حسی از ساختار صفحه، حتی پیش از رسیدن داده واقعی، دارد.

اصل مهم: fallback باید فضای واقعی محتوا را اشغال کند

یک اشتباه رایج، طراحی fallbackی است که ابعاد کاملاً متفاوتی از محتوای نهایی دارد؛ مثلاً یک خط متن کوتاه به‌جای یک کارت بزرگ. در این حالت، وقتی محتوای واقعی جایگزین می‌شود، بقیه عناصر صفحه، ناگهان جابه‌جا می‌شوند. طراحی fallback با ابعاد نزدیک به واقعیت، دقیقاً مثل تعیین width و height از پیش برای یک تصویر در HTML معمولی، از این پرش جلوگیری می‌کند.

دانه‌بندی fallback: یک مرز بزرگ در برابر چند مرز کوچک

همان‌طور که در درس قبل با چند Suspense مستقل دیدیم، می‌توان یک fallback واحد و بزرگ برای کل صفحه، یا چند fallback کوچک‌تر برای هر بخش جداگانه داشت. انتخاب بین این دو، یک Trade-off آشناست: یک مرز واحد، ساده‌تر است اما یعنی تمام بخش‌ها با هم ظاهر می‌شوند (حتی اگر یکی از آن‌ها سریع آماده باشد)؛ چند مرز کوچک، تجربه تدریجی‌تری می‌دهد اما فضای صفحه، در طول بارگذاری، چیدمانی پراکنده‌تر و کمتر یکپارچه خواهد داشت.

fallback نباید خودش کند یا سنگین باشد

چون fallback معمولاً بلافاصله و در اولین Render ظاهر می‌شود، باید خودش یک کامپوننت بسیار سبک باشد؛ هیچ منطقی ندارد که برای نمایش یک حالت موقتی، منتظر بارگذاری یک کامپوننت سنگین دیگر بمانیم. به همین دلیل، تقریباً همیشه fallback، یک JSX استاتیک و ساده است، نه چیزی که خودش به داده یا محاسبه وابسته باشد.

مثال قابل اجرا

در این مثال، یک Skeleton ساده با ابعاد نزدیک به محتوای واقعی، به‌جای یک متن ساده، استفاده شده تا تفاوت تجربه کاربری دیده شود.

مقایسه یک Spinner ساده با یک Skeleton هم‌اندازه با محتوا
import { Suspense, lazy } from "react";

function ProfileSkeleton() {
  return (
    <div style={{ border: "1px solid #ddd", padding: "10px", width: "220px" }}>
      <div style={{ background: "#eee", height: "16px", width: "60%", marginBottom: "8px" }} />
      <div style={{ background: "#eee", height: "12px", width: "90%" }} />
    </div>
  );
}

const ProfileCard = lazy(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        default: function ProfileCard() {
          return (
            <div style={{ border: "1px solid #ddd", padding: "10px", width: "220px" }}>
              <strong>سارا محمدی</strong>
              <p>توسعه‌دهنده React</p>
            </div>
          );
        },
      });
    }, 1200);
  });
});

function App() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ProfileCard />
    </Suspense>
  );
}
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

یک fallback خوب برای Suspense، معمولاً یک Skeleton با ابعاد نزدیک به محتوای واقعی است، نه یک Spinner بی‌شکل؛ این کار از پرش ناگهانی چیدمان صفحه پس از رسیدن داده جلوگیری می‌کند. انتخاب دانه‌بندی fallback (یک مرز بزرگ یا چند مرز کوچک) یک تصمیم طراحی است، و خود fallback باید همیشه سبک و فوری باشد. در درس بعدی، به ترکیب Suspense با Lazy Loading در عمق بیشتر می‌پردازیم.