Українська

Опануйте React Suspense для завантаження даних. Навчіться декларативно керувати станами завантаження, покращувати UX за допомогою переходів та обробляти помилки з Error Boundaries.

Межі React Suspense: Глибоке занурення в декларативне керування станами завантаження

У світі сучасної веб-розробки створення безшовного та чутливого користувацького досвіду є першочерговим. Одним із найскладніших викликів, з якими стикаються розробники, є керування станами завантаження. Від отримання даних для профілю користувача до завантаження нової секції застосунку — моменти очікування є критичними. Історично це включало заплутану мережу булевих прапорців, таких як isLoading, isFetching та hasError, розкиданих по наших компонентах. Цей імперативний підхід захаращує наш код, ускладнює логіку та є частою причиною помилок, таких як стани гонитви (race conditions).

І тут з'являється React Suspense. Спочатку представлений для розділення коду (code-splitting) за допомогою React.lazy(), його можливості значно розширилися з виходом React 18, перетворившись на потужний, першокласний механізм для обробки асинхронних операцій, особливо завантаження даних. Suspense дозволяє нам керувати станами завантаження декларативно, кардинально змінюючи спосіб написання та осмислення наших компонентів. Замість того, щоб питати "Чи я завантажуюся?", наші компоненти можуть просто сказати, "Мені потрібні ці дані для рендерингу. Поки я чекаю, будь ласка, покажи цей запасний UI."

Цей вичерпний посібник проведе вас шляхом від традиційних методів керування станом до декларативної парадигми React Suspense. Ми дослідимо, що таке межі Suspense, як вони працюють для розділення коду та завантаження даних, а також як організувати складні інтерфейси завантаження, які будуть радувати ваших користувачів, а не розчаровувати їх.

Старий підхід: рутинне керування станами завантаження

Перш ніж ми зможемо повністю оцінити елегантність Suspense, важливо зрозуміти проблему, яку він вирішує. Розгляньмо типовий компонент, який завантажує дані за допомогою хуків useEffect та useState.

Уявіть компонент, якому потрібно завантажити та відобразити дані користувача:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reset state for new userId
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Re-fetch when userId changes

  if (isLoading) {
    return <p>Loading profile...</p>;
  }

  if (error) {
    return <p>Error: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Цей патерн є функціональним, але має кілька недоліків:

Знайомтеся з React Suspense: Зміна парадигми

Suspense перевертає цю модель з ніг на голову. Замість того, щоб компонент керував станом завантаження внутрішньо, він повідомляє про свою залежність від асинхронної операції безпосередньо React. Якщо дані, які йому потрібні, ще недоступні, компонент "призупиняє" рендеринг.

Коли компонент призупиняється, React піднімається вгору по дереву компонентів, щоб знайти найближчу межу Suspense (Suspense Boundary). Межа Suspense — це компонент, який ви визначаєте у своєму дереві за допомогою <Suspense>. Ця межа буде рендерити запасний UI (наприклад, спінер або скелетний завантажувач), доки всі компоненти всередині неї не отримають свої дані.

Основна ідея полягає в тому, щоб розміщувати залежність від даних разом із компонентом, який їх потребує, водночас централізуючи UI завантаження на вищому рівні в дереві компонентів. Це очищує логіку компонентів і дає вам потужний контроль над досвідом завантаження для користувача.

Як компонент "призупиняється"?

Магія Suspense полягає в патерні, який спочатку може здатися незвичним: викидання Promise. Джерело даних, що підтримує Suspense, працює так:

  1. Коли компонент запитує дані, джерело даних перевіряє, чи є вони в кеші.
  2. Якщо дані доступні, воно повертає їх синхронно.
  3. Якщо дані недоступні (тобто, вони зараз завантажуються), джерело даних викидає Promise, що представляє поточний запит на завантаження.

React перехоплює цей викинутий Promise. Це не призводить до збою вашого застосунку. Натомість, він інтерпретує це як сигнал: "Цей компонент ще не готовий до рендерингу. Призупини його і знайди вище межу Suspense, щоб показати запасний UI." Як тільки Promise виконається, React спробує відрендерити компонент знову, який тепер отримає свої дані та успішно відобразиться.

Межа <Suspense>: Ваш декларатор UI завантаження

Компонент <Suspense> є серцем цього патерну. Він неймовірно простий у використанні й приймає один обов'язковий пропс: fallback.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>My Application</h1>
      <Suspense fallback={<p>Loading content...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

У цьому прикладі, якщо SomeComponentThatFetchesData призупиниться, користувач побачить повідомлення "Завантаження контенту...", доки дані не будуть готові. Запасним UI (fallback) може бути будь-який валідний вузол React, від простого рядка до складного скелетного компонента.

Класичний випадок використання: Розділення коду з React.lazy()

Найбільш відоме застосування Suspense — це розділення коду. Воно дозволяє відкласти завантаження JavaScript для компонента доти, доки він справді не знадобиться.


import React, { Suspense, lazy } from 'react';

// This component's code won't be in the initial bundle.
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>Some content that loads immediately</h2>
      <Suspense fallback={<div>Loading component...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

Тут React завантажить JavaScript для HeavyComponent тільки тоді, коли вперше спробує його відрендерити. Поки він завантажується та парситься, відображається запасний UI з Suspense. Це потужна техніка для покращення початкового часу завантаження сторінки.

Сучасний рубіж: Завантаження даних із Suspense

Хоча React надає механізм Suspense, він не надає конкретного клієнта для завантаження даних. Щоб використовувати Suspense для завантаження даних, вам потрібне джерело даних, яке з ним інтегрується (тобто, таке, що викидає Promise, коли дані очікуються).

Фреймворки, такі як Relay та Next.js, мають вбудовану, першокласну підтримку Suspense. Популярні бібліотеки для завантаження даних, як-от TanStack Query (раніше React Query) та SWR, також пропонують експериментальну або повну його підтримку.

Щоб зрозуміти концепцію, давайте створимо дуже просту, концептуальну обгортку навколо fetch API, щоб зробити її сумісною з Suspense. Примітка: Це спрощений приклад для навчальних цілей, він не готовий до використання в продакшені. Йому бракує належного кешування та тонкощів обробки помилок.


// data-fetcher.js
// A simple cache to store results
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // This is the magic!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Fetch failed with status ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

Ця обгортка підтримує простий статус для кожної URL-адреси. Коли викликається fetchData, вона перевіряє статус. Якщо він 'pending', вона викидає promise. Якщо він 'success', вона повертає дані. Тепер перепишемо наш компонент UserProfile, використовуючи це.


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// The component that actually uses the data
function ProfileDetails({ userId }) {
  // Try to read the data. If it's not ready, this will suspend.
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

// The parent component that defines the loading state UI
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

Подивіться на різницю! Компонент ProfileDetails є чистим і зосередженим виключно на рендерингу даних. Він не має станів isLoading або error. Він просто запитує дані, які йому потрібні. Відповідальність за показ індикатора завантаження перенесена на батьківський компонент, UserProfile, який декларативно визначає, що показувати під час очікування.

Організація складних станів завантаження

Справжня сила Suspense стає очевидною, коли ви створюєте складні UI з кількома асинхронними залежностями.

Вкладені межі Suspense для поетапного UI

Ви можете вкладати межі Suspense, щоб створити більш витончений досвід завантаження. Уявіть сторінку панелі інструментів з бічною панеллю, основною областю контенту та списком останніх активностей. Кожен із цих елементів може вимагати власного завантаження даних.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="layout">
        <Suspense fallback={<p>Loading navigation...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

З такою структурою:

Це дозволяє вам показувати корисний контент користувачеві якомога швидше, значно покращуючи сприйняття продуктивності.

Уникнення ефекту "попкорну" в UI

Іноді поетапний підхід може призвести до різкого ефекту, коли кілька спінерів з'являються та зникають у швидкій послідовності — ефект, який часто називають 'попкорнінгом' (popcorning). Щоб вирішити цю проблему, ви можете перемістити межу Suspense вище по дереву компонентів.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

У цій версії єдиний DashboardSkeleton показується доти, доки всі дочірні компоненти (Sidebar, MainContent, ActivityFeed) не отримають свої дані. Потім вся панель інструментів з'являється одночасно. Вибір між вкладеними межами та єдиною межею вищого рівня — це дизайнерське рішення UX, яке Suspense робить тривіальним для реалізації.

Обробка помилок за допомогою Error Boundaries

Suspense обробляє стан очікування (pending) промісу, але що щодо стану відхилення (rejected)? Якщо проміс, викинутий компонентом, відхиляється (наприклад, через мережеву помилку), це буде трактуватися як будь-яка інша помилка рендерингу в React.

Рішенням є використання меж помилок (Error Boundaries). Межа помилок — це класовий компонент, який визначає спеціальний метод життєвого циклу, componentDidCatch() або статичний метод getDerivedStateFromError(). Він перехоплює помилки JavaScript у будь-якому місці свого дочірнього дерева компонентів, логує ці помилки та відображає запасний UI.

Ось простий компонент Error Boundary:


import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // Оновити стан, щоб наступний рендер показав запасний UI.
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // Ви також можете логувати помилку в сервіс звітування про помилки
    console.error("Caught an error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Ви можете рендерити будь-який власний запасний UI
      return <h1>Something went wrong. Please try again.</h1>;
    }

    return this.props.children; 
  }
}

Потім ви можете комбінувати Error Boundaries із Suspense, щоб створити надійну систему, яка обробляє всі три стани: очікування, успіх та помилку.


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>User Information</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>Loading...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

З цим патерном, якщо завантаження даних всередині UserProfile завершується успішно, показується профіль. Якщо воно в стані очікування, показується запасний UI Suspense. Якщо воно зазнає невдачі, показується запасний UI межі помилок. Логіка є декларативною, композиційною та легкою для розуміння.

Переходи (Transitions): ключ до неблокуючих оновлень UI

Є ще один останній елемент пазла. Розгляньмо взаємодію користувача, яка запускає нове завантаження даних, наприклад, натискання кнопки "Далі" для перегляду іншого профілю користувача. З налаштуванням, описаним вище, в момент натискання кнопки та зміни пропса userId, компонент UserProfile знову призупиниться. Це означає, що поточний видимий профіль зникне і буде замінений на запасний UI завантаження. Це може відчуватися як різка та руйнівна дія.

Саме тут на допомогу приходять переходи (transitions). Переходи — це нова функція в React 18, яка дозволяє позначати певні оновлення стану як нетермінові. Коли оновлення стану обгорнуте в перехід, React продовжує показувати старий UI (застарілий контент), поки готує новий контент у фоновому режимі. Він застосує оновлення UI лише тоді, коли новий контент буде готовий до відображення.

Основний API для цього — хук useTransition.


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        Next User
      </button>

      {isPending && <span> Loading new profile...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>Loading initial profile...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Ось що відбувається тепер:

  1. Початковий профіль для userId: 1 завантажується, показуючи запасний UI Suspense.
  2. Користувач натискає "Наступний користувач".
  3. Виклик setUserId обгорнутий у startTransition.
  4. React починає рендерити UserProfile з новим userId рівним 2 у пам'яті. Це змушує його призупинитися.
  5. Найважливіше, замість того, щоб показувати запасний UI Suspense, React залишає старий UI (профіль користувача 1) на екрані.
  6. Булеве значення isPending, що повертається useTransition, стає true, що дозволяє нам показувати непомітний вбудований індикатор завантаження, не розмонтовуючи старий контент.
  7. Як тільки дані для користувача 2 завантажені і UserProfile може успішно відрендеритися, React застосовує оновлення, і новий профіль плавно з'являється.

Переходи надають останній рівень контролю, дозволяючи вам створювати витончені та дружні до користувача досвіди завантаження, які ніколи не здаються різкими.

Найкращі практики та загальні рекомендації

Висновок

React Suspense представляє собою більше, ніж просто нову функцію; це фундаментальна еволюція в нашому підході до асинхронності в React-застосунках. Відходячи від ручних, імперативних прапорців завантаження та приймаючи декларативну модель, ми можемо писати компоненти, які є чистішими, стійкішими та легшими для композиції.

Поєднуючи <Suspense> для станів очікування, Error Boundaries для станів помилок та useTransition для безшовних оновлень, ви маєте у своєму розпорядженні повний і потужний набір інструментів. Ви можете організувати все: від простих спінерів завантаження до складних, поетапних появ панелей інструментів з мінімальним, передбачуваним кодом. Коли ви почнете інтегрувати Suspense у свої проєкти, ви виявите, що він не тільки покращує продуктивність вашого застосунку та користувацький досвід, але й значно спрощує логіку керування станом, дозволяючи вам зосередитися на тому, що справді має значення: створенні чудових функцій.