Tiếng Việt

Làm chủ React Suspense để tìm nạp dữ liệu. Học cách quản lý trạng thái tải một cách khai báo, cải thiện UX với transitions và xử lý lỗi bằng Error Boundaries.

Ranh giới Suspense trong React: Tìm hiểu sâu về Quản lý Trạng thái Tải một cách Khai báo

Trong thế giới phát triển web hiện đại, việc tạo ra trải nghiệm người dùng liền mạch và linh hoạt là tối quan trọng. Một trong những thách thức dai dẳng nhất mà các nhà phát triển phải đối mặt là quản lý trạng thái tải. Từ việc tìm nạp dữ liệu cho hồ sơ người dùng đến việc tải một phần mới của ứng dụng, những khoảnh khắc chờ đợi là rất quan trọng. Trong lịch sử, điều này liên quan đến một mớ cờ boolean rối rắm như isLoading, isFetching, và hasError, rải rác khắp các component của chúng ta. Cách tiếp cận mệnh lệnh này làm lộn xộn code, phức tạp hóa logic và là nguồn gốc thường xuyên của các lỗi, chẳng hạn như race conditions.

Và React Suspense đã ra đời. Ban đầu được giới thiệu cho việc chia tách code (code-splitting) với React.lazy(), khả năng của nó đã mở rộng đáng kể với React 18 để trở thành một cơ chế mạnh mẽ, hạng nhất để xử lý các hoạt động bất đồng bộ, đặc biệt là tìm nạp dữ liệu. Suspense cho phép chúng ta quản lý trạng thái tải theo cách khai báo, thay đổi cơ bản cách chúng ta viết và suy luận về các component của mình. Thay vì hỏi "Tôi có đang tải không?", các component của chúng ta có thể chỉ cần nói, "Tôi cần dữ liệu này để render. Trong khi chờ đợi, vui lòng hiển thị giao diện người dùng dự phòng này."

Hướng dẫn toàn diện này sẽ đưa bạn vào một cuộc hành trình từ các phương pháp quản lý trạng thái truyền thống đến mô hình khai báo của React Suspense. Chúng ta sẽ khám phá ranh giới Suspense là gì, cách chúng hoạt động cho cả việc chia tách code và tìm nạp dữ liệu, và cách dàn dựng các giao diện người dùng tải phức tạp làm hài lòng người dùng thay vì làm họ thất vọng.

Cách Cũ: Công việc Tẻ nhạt của Trạng thái Tải Thủ công

Trước khi chúng ta có thể đánh giá đầy đủ sự tinh tế của Suspense, điều cần thiết là phải hiểu vấn đề mà nó giải quyết. Hãy xem một component điển hình tìm nạp dữ liệu bằng cách sử dụng hook useEffectuseState.

Hãy tưởng tượng một component cần tìm nạp và hiển thị dữ liệu người dùng:


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(() => {
    // Đặt lại trạng thái cho userId mới
    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('Phản hồi mạng không ổn');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Tìm nạp lại khi userId thay đổi

  if (isLoading) {
    return <p>Đang tải hồ sơ...</p>;
  }

  if (error) {
    return <p>Lỗi: {error.message}</p>;
  }

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

Mẫu này hoạt động được, nhưng nó có một số nhược điểm:

Sự ra đời của React Suspense: Một sự thay đổi Mô hình

Suspense đảo ngược mô hình này. Thay vì component tự quản lý trạng thái tải bên trong, nó giao tiếp sự phụ thuộc của mình vào một hoạt động bất đồng bộ trực tiếp với React. Nếu dữ liệu nó cần chưa có sẵn, component sẽ "tạm dừng" (suspend) việc render.

Khi một component tạm dừng, React sẽ đi lên cây component để tìm Ranh giới Suspense (Suspense Boundary) gần nhất. Một Ranh giới Suspense là một component bạn định nghĩa trong cây của mình bằng cách sử dụng <Suspense>. Ranh giới này sau đó sẽ render một giao diện người dùng dự phòng (như một spinner hoặc một skeleton loader) cho đến khi tất cả các component bên trong nó đã giải quyết xong các phụ thuộc dữ liệu của chúng.

Ý tưởng cốt lõi là đặt sự phụ thuộc dữ liệu cùng vị trí với component cần nó, trong khi tập trung giao diện người dùng tải ở một cấp độ cao hơn trong cây component. Điều này làm sạch logic của component và cho bạn quyền kiểm soát mạnh mẽ đối với trải nghiệm tải của người dùng.

Làm thế nào một Component "Tạm dừng"?

Phép màu đằng sau Suspense nằm ở một mẫu có thể trông khác thường lúc đầu: ném ra một Promise. Một nguồn dữ liệu hỗ trợ Suspense hoạt động như sau:

  1. Khi một component yêu cầu dữ liệu, nguồn dữ liệu sẽ kiểm tra xem nó có dữ liệu trong bộ đệm (cache) hay không.
  2. Nếu dữ liệu có sẵn, nó sẽ trả về đồng bộ.
  3. Nếu dữ liệu không có sẵn (tức là nó đang được tìm nạp), nguồn dữ liệu sẽ ném ra Promise đại diện cho yêu cầu tìm nạp đang diễn ra.

React bắt được Promise bị ném ra này. Nó không làm ứng dụng của bạn bị lỗi. Thay vào đó, nó diễn giải nó như một tín hiệu: "Component này chưa sẵn sàng để render. Hãy tạm dừng nó, và tìm một ranh giới Suspense phía trên để hiển thị một giao diện dự phòng." Khi Promise được giải quyết, React sẽ thử render lại component, lúc này sẽ nhận được dữ liệu của nó và render thành công.

Ranh giới <Suspense>: Công cụ Khai báo UI Tải của bạn

Component <Suspense> là trái tim của mẫu này. Nó cực kỳ đơn giản để sử dụng, chỉ nhận một prop bắt buộc duy nhất: fallback.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>Ứng dụng của tôi</h1>
      <Suspense fallback={<p>Đang tải nội dung...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

Trong ví dụ này, nếu SomeComponentThatFetchesData tạm dừng, người dùng sẽ thấy thông báo "Đang tải nội dung..." cho đến khi dữ liệu sẵn sàng. fallback có thể là bất kỳ node React hợp lệ nào, từ một chuỗi đơn giản đến một component skeleton phức tạp.

Trường hợp sử dụng cổ điển: Chia tách Code với React.lazy()

Cách sử dụng đã được thiết lập vững chắc nhất của Suspense là cho việc chia tách code (code splitting). Nó cho phép bạn trì hoãn việc tải JavaScript cho một component cho đến khi nó thực sự cần thiết.


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

// Code của component này sẽ không nằm trong gói ban đầu.
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>Một số nội dung tải ngay lập tức</h2>
      <Suspense fallback={<div>Đang tải component...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

Ở đây, React sẽ chỉ tìm nạp JavaScript cho HeavyComponent khi nó lần đầu tiên cố gắng render nó. Trong khi nó đang được tìm nạp và phân tích, fallback của Suspense được hiển thị. Đây là một kỹ thuật mạnh mẽ để cải thiện thời gian tải trang ban đầu.

Miền đất Hiện đại: Tìm nạp Dữ liệu với Suspense

Mặc dù React cung cấp cơ chế Suspense, nó không cung cấp một client tìm nạp dữ liệu cụ thể. Để sử dụng Suspense cho việc tìm nạp dữ liệu, bạn cần một nguồn dữ liệu tích hợp với nó (tức là một nguồn ném ra Promise khi dữ liệu đang chờ xử lý).

Các framework như Relay và Next.js có hỗ trợ Suspense tích hợp, hạng nhất. Các thư viện tìm nạp dữ liệu phổ biến như TanStack Query (trước đây là React Query) và SWR cũng cung cấp hỗ trợ thử nghiệm hoặc đầy đủ cho nó.

Để hiểu khái niệm này, hãy tạo một trình bao bọc (wrapper) rất đơn giản, mang tính khái niệm xung quanh API fetch để làm cho nó tương thích với Suspense. Lưu ý: Đây là một ví dụ đơn giản hóa cho mục đích giáo dục và chưa sẵn sàng cho môi trường sản phẩm. Nó thiếu bộ đệm thích hợp và các chi tiết xử lý lỗi phức tạp.


// data-fetcher.js
// Một bộ đệm đơn giản để lưu trữ kết quả
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; // Đây chính là điều kỳ diệu!
  }
  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(`Tìm nạp thất bại với trạng thái ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

Trình bao bọc này duy trì một trạng thái đơn giản cho mỗi URL. Khi fetchData được gọi, nó kiểm tra trạng thái. Nếu đang chờ xử lý, nó sẽ ném ra promise. Nếu thành công, nó sẽ trả về dữ liệu. Bây giờ, hãy viết lại component UserProfile của chúng ta bằng cách sử dụng nó.


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

// Component thực sự sử dụng dữ liệu
function ProfileDetails({ userId }) {
  // Cố gắng đọc dữ liệu. Nếu chưa sẵn sàng, nó sẽ tạm dừng.
  const user = fetchData(`https://api.example.com/users/${userId}`);

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

// Component cha định nghĩa UI trạng thái tải
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>Đang tải hồ sơ...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

Hãy nhìn vào sự khác biệt! Component ProfileDetails gọn gàng và chỉ tập trung vào việc render dữ liệu. Nó không có trạng thái isLoading hoặc error. Nó chỉ đơn giản yêu cầu dữ liệu mà nó cần. Trách nhiệm hiển thị một chỉ báo tải đã được chuyển lên component cha, UserProfile, nơi khai báo những gì sẽ hiển thị trong khi chờ đợi.

Dàn dựng các Trạng thái Tải Phức tạp

Sức mạnh thực sự của Suspense trở nên rõ ràng khi bạn xây dựng các giao diện người dùng phức tạp với nhiều phụ thuộc bất đồng bộ.

Các Ranh giới Suspense Lồng nhau cho một Giao diện Người dùng Phân cấp

Bạn có thể lồng các ranh giới Suspense để tạo ra một trải nghiệm tải tinh tế hơn. Hãy tưởng tượng một trang dashboard với một sidebar, một khu vực nội dung chính và một danh sách các hoạt động gần đây. Mỗi phần này có thể yêu cầu tìm nạp dữ liệu riêng.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="layout">
        <Suspense fallback={<p>Đang tải điều hướng...</p>}>
          <Sidebar />
        </Suspense>

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

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

Với cấu trúc này:

Điều này cho phép bạn hiển thị nội dung hữu ích cho người dùng nhanh nhất có thể, cải thiện đáng kể hiệu suất cảm nhận được.

Tránh hiện tượng UI "Nổ Bỏng ngô" (Popcorning)

Đôi khi, cách tiếp cận phân cấp có thể dẫn đến một hiệu ứng khó chịu khi nhiều spinner xuất hiện và biến mất liên tiếp nhanh chóng, một hiệu ứng thường được gọi là "popcorning". Để giải quyết vấn đề này, bạn có thể di chuyển ranh giới Suspense lên cao hơn trong cây.


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

Trong phiên bản này, một DashboardSkeleton duy nhất được hiển thị cho đến khi tất cả các component con (Sidebar, MainContent, ActivityFeed) có dữ liệu sẵn sàng. Toàn bộ dashboard sau đó sẽ xuất hiện cùng một lúc. Sự lựa chọn giữa các ranh giới lồng nhau và một ranh giới cấp cao duy nhất là một quyết định thiết kế UX mà Suspense giúp việc triển khai trở nên dễ dàng.

Xử lý Lỗi với Ranh giới Lỗi (Error Boundaries)

Suspense xử lý trạng thái đang chờ (pending) của một promise, nhưng còn trạng thái bị từ chối (rejected) thì sao? Nếu promise bị ném ra bởi một component bị từ chối (ví dụ: lỗi mạng), nó sẽ được coi như bất kỳ lỗi render nào khác trong React.

Giải pháp là sử dụng Ranh giới Lỗi (Error Boundaries). Một Error Boundary là một class component định nghĩa một phương thức vòng đời đặc biệt, componentDidCatch() hoặc một phương thức tĩnh getDerivedStateFromError(). Nó bắt các lỗi JavaScript ở bất kỳ đâu trong cây component con của nó, ghi lại các lỗi đó và hiển thị một giao diện người dùng dự phòng.

Đây là một component Error Boundary đơn giản:


import React from 'react';

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

  static getDerivedStateFromError(error) {
    // Cập nhật state để lần render tiếp theo sẽ hiển thị UI dự phòng.
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // Bạn cũng có thể ghi lại lỗi vào một dịch vụ báo cáo lỗi
    console.error("Đã bắt được lỗi:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Bạn có thể render bất kỳ UI dự phòng tùy chỉnh nào
      return <h1>Đã xảy ra lỗi. Vui lòng thử lại.</h1>;
    }

    return this.props.children; 
  }
}

Sau đó, bạn có thể kết hợp Error Boundaries với Suspense để tạo ra một hệ thống mạnh mẽ xử lý cả ba trạng thái: đang chờ, thành công và lỗi.


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

function App() {
  return (
    <div>
      <h2>Thông tin người dùng</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>Đang tải...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Với mẫu này, nếu việc tìm nạp dữ liệu bên trong UserProfile thành công, hồ sơ sẽ được hiển thị. Nếu đang chờ xử lý, fallback của Suspense sẽ được hiển thị. Nếu thất bại, fallback của Error Boundary sẽ được hiển thị. Logic này mang tính khai báo, có thể kết hợp và dễ dàng suy luận.

Transitions: Chìa khóa cho các Cập nhật Giao diện người dùng không Chặn

Còn một mảnh ghép cuối cùng. Hãy xem xét một tương tác của người dùng kích hoạt một lần tìm nạp dữ liệu mới, như nhấp vào nút "Tiếp theo" để xem một hồ sơ người dùng khác. Với thiết lập ở trên, ngay khi nút được nhấp và prop userId thay đổi, component UserProfile sẽ lại tạm dừng. Điều này có nghĩa là hồ sơ hiện đang hiển thị sẽ biến mất và được thay thế bằng fallback tải. Điều này có thể gây cảm giác đột ngột và khó chịu.

Đây là lúc transitions phát huy tác dụng. Transitions là một tính năng mới trong React 18 cho phép bạn đánh dấu một số cập nhật trạng thái là không khẩn cấp. Khi một cập nhật trạng thái được bao bọc trong một transition, React sẽ tiếp tục hiển thị giao diện người dùng cũ (nội dung cũ) trong khi nó chuẩn bị nội dung mới ở chế độ nền. Nó sẽ chỉ thực hiện cập nhật giao diện người dùng khi nội dung mới đã sẵn sàng để được hiển thị.

API chính cho việc này là hook 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}>
        Người dùng tiếp theo
      </button>

      {isPending && <span> Đang tải hồ sơ mới...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>Đang tải hồ sơ ban đầu...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Đây là những gì xảy ra bây giờ:

  1. Hồ sơ ban đầu cho userId: 1 tải, hiển thị fallback của Suspense.
  2. Người dùng nhấp vào "Người dùng tiếp theo".
  3. Lệnh gọi setUserId được bao bọc trong startTransition.
  4. React bắt đầu render UserProfile với userId mới là 2 trong bộ nhớ. Điều này khiến nó tạm dừng.
  5. Quan trọng là, thay vì hiển thị fallback của Suspense, React giữ lại giao diện người dùng cũ (hồ sơ của người dùng 1) trên màn hình.
  6. Giá trị boolean isPending được trả về bởi useTransition trở thành true, cho phép chúng ta hiển thị một chỉ báo tải tinh tế, nội tuyến mà không cần gỡ bỏ nội dung cũ.
  7. Khi dữ liệu cho người dùng 2 được tìm nạp và UserProfile có thể render thành công, React thực hiện cập nhật, và hồ sơ mới xuất hiện một cách liền mạch.

Transitions cung cấp lớp kiểm soát cuối cùng, cho phép bạn xây dựng các trải nghiệm tải phức tạp và thân thiện với người dùng mà không bao giờ gây cảm giác khó chịu.

Các Thực hành Tốt nhất và Cân nhắc Toàn cục

Kết luận

React Suspense đại diện cho nhiều hơn chỉ là một tính năng mới; đó là một sự tiến hóa cơ bản trong cách chúng ta tiếp cận tính bất đồng bộ trong các ứng dụng React. Bằng cách chuyển từ các cờ tải thủ công, mệnh lệnh và đón nhận một mô hình khai báo, chúng ta có thể viết các component sạch hơn, linh hoạt hơn và dễ dàng kết hợp hơn.

Bằng cách kết hợp <Suspense> cho các trạng thái đang chờ, Error Boundaries cho các trạng thái thất bại, và useTransition cho các cập nhật liền mạch, bạn có một bộ công cụ hoàn chỉnh và mạnh mẽ trong tay. Bạn có thể dàn dựng mọi thứ từ các spinner tải đơn giản đến các dashboard phức tạp, hiển thị theo từng giai đoạn với code tối thiểu và có thể dự đoán. Khi bạn bắt đầu tích hợp Suspense vào các dự án của mình, bạn sẽ thấy nó không chỉ cải thiện hiệu suất và trải nghiệm người dùng của ứng dụng mà còn đơn giản hóa đáng kể logic quản lý trạng thái của bạn, cho phép bạn tập trung vào những gì thực sự quan trọng: xây dựng các tính năng tuyệt vời.