Tiếng Việt

Khám phá React Suspense cho việc tìm nạp dữ liệu ngoài code splitting. Hiểu rõ Fetch-As-You-Render, xử lý lỗi, và các mẫu cho ứng dụng toàn cầu trong tương lai.

Tải Tài Nguyên với React Suspense: Làm Chủ Các Mẫu Tìm Nạp Dữ Liệu Hiện Đại

Trong thế giới phát triển web năng động, trải nghiệm người dùng (UX) là yếu tố tối thượng. Các ứng dụng được kỳ vọng phải nhanh, đáp ứng tốt và thú vị, bất kể điều kiện mạng hay khả năng của thiết bị. Đối với các nhà phát triển React, điều này thường dẫn đến việc quản lý trạng thái phức tạp, các chỉ báo tải rắc rối và một cuộc chiến không ngừng chống lại các thác nước tìm nạp dữ liệu (data fetching waterfalls). Đây là lúc React Suspense xuất hiện, một tính năng mạnh mẽ, dù thường bị hiểu lầm, được thiết kế để thay đổi cơ bản cách chúng ta xử lý các hoạt động bất đồng bộ, đặc biệt là tìm nạp dữ liệu.

Ban đầu được giới thiệu cho việc tách mã (code splitting) với React.lazy(), tiềm năng thực sự của Suspense nằm ở khả năng điều phối việc tải *bất kỳ* tài nguyên bất đồng bộ nào, bao gồm cả dữ liệu từ API. Hướng dẫn toàn diện này sẽ đi sâu vào React Suspense để tải tài nguyên, khám phá các khái niệm cốt lõi, các mẫu tìm nạp dữ liệu cơ bản và những cân nhắc thực tế để xây dựng các ứng dụng toàn cầu hiệu suất cao và linh hoạt.

Sự Tiến Hóa của Việc Tìm Nạp Dữ Liệu trong React: Từ Mệnh Lệnh đến Khai Báo

Trong nhiều năm, việc tìm nạp dữ liệu trong các component React chủ yếu dựa vào một mẫu chung: sử dụng hook useEffect để khởi tạo một lệnh gọi API, quản lý trạng thái tải và lỗi bằng useState, và render có điều kiện dựa trên các trạng thái này. Mặc dù hoạt động được, cách tiếp cận này thường dẫn đến một số thách thức:

Hãy xem xét một kịch bản tìm nạp dữ liệu điển hình không có Suspense:

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(() => {
    const fetchUser = async () => {
      try {
        setIsLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        setUser(data);
      } catch (e) {
        setError(e);
      } finally {
        setIsLoading(false);
      }
    };
    fetchUser();
  }, [userId]);

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

  if (error) {
    return <p style={"color: red;"}>Lỗi: {error.message}</p>;
  }

  if (!user) {
    return <p>Không có dữ liệu người dùng.</p>;
  }

  return (
    <div>
      <h2>Người dùng: {user.name}</h2>
      <p>Email: {user.email}</p>
      <!-- Thêm chi tiết người dùng -->
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Chào mừng đến với Ứng dụng</h1>
      <UserProfile userId={"123"} />
    </div>
  );
}

Mẫu này rất phổ biến, nhưng nó buộc component phải tự quản lý trạng thái bất đồng bộ của mình, thường dẫn đến mối quan hệ耦合 chặt chẽ giữa UI và logic tìm nạp dữ liệu. Suspense cung cấp một giải pháp thay thế mang tính khai báo và tinh gọn hơn.

Hiểu React Suspense Ngoài Việc Tách Mã (Code Splitting)

Hầu hết các nhà phát triển lần đầu tiên gặp Suspense qua React.lazy() để tách mã, nơi nó cho phép bạn trì hoãn việc tải mã của một component cho đến khi cần thiết. Ví dụ:

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

const LazyComponent = lazy(() => import('./MyHeavyComponent'));

function App() {
  return (
    <Suspense fallback={<div>Đang tải component...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

Trong kịch bản này, nếu MyHeavyComponent chưa được tải, ranh giới <Suspense> sẽ bắt promise được ném ra bởi lazy() và hiển thị fallback cho đến khi mã của component sẵn sàng. Điểm mấu chốt ở đây là Suspense hoạt động bằng cách bắt các promise được ném ra trong quá trình render.

Cơ chế này không chỉ dành riêng cho việc tải mã. Bất kỳ hàm nào được gọi trong quá trình render mà ném ra một promise (ví dụ: vì một tài nguyên chưa có sẵn) đều có thể bị bắt bởi một ranh giới Suspense ở cấp cao hơn trong cây component. Khi promise được giải quyết, React sẽ cố gắng render lại component, và nếu tài nguyên đã có sẵn, fallback sẽ bị ẩn đi và nội dung thực tế sẽ được hiển thị.

Các Khái Niệm Cốt Lõi của Suspense cho Việc Tìm Nạp Dữ Liệu

Để tận dụng Suspense cho việc tìm nạp dữ liệu, chúng ta cần hiểu một vài nguyên tắc cốt lõi:

1. Ném ra một Promise (Throwing a Promise)

Không giống như mã bất đồng bộ truyền thống sử dụng async/await để giải quyết các promise, Suspense dựa vào một hàm *ném ra* một promise nếu dữ liệu chưa sẵn sàng. Khi React cố gắng render một component gọi một hàm như vậy, và dữ liệu vẫn đang chờ xử lý, promise sẽ được ném ra. React sau đó sẽ 'tạm dừng' việc render của component đó và các con của nó, tìm kiếm ranh giới <Suspense> gần nhất.

2. Ranh giới Suspense (The Suspense Boundary)

Component <Suspense> hoạt động như một ranh giới lỗi cho các promise. Nó nhận một prop fallback, là UI sẽ được render trong khi bất kỳ component con nào của nó (hoặc hậu duệ của chúng) đang tạm dừng (tức là đang ném ra một promise). Một khi tất cả các promise được ném ra trong cây con của nó được giải quyết, fallback sẽ được thay thế bằng nội dung thực tế.

Một ranh giới Suspense duy nhất có thể quản lý nhiều hoạt động bất đồng bộ. Ví dụ, nếu bạn có hai component trong cùng một ranh giới <Suspense>, và mỗi component cần tìm nạp dữ liệu, fallback sẽ hiển thị cho đến khi *cả hai* quá trình tìm nạp dữ liệu hoàn tất. Điều này tránh hiển thị UI một phần và cung cấp một trải nghiệm tải phối hợp hơn.

3. Trình Quản Lý Cache/Tài Nguyên (Trách nhiệm của Userland)

Quan trọng là, bản thân Suspense không xử lý việc tìm nạp dữ liệu hay caching. Nó chỉ là một cơ chế điều phối. Để làm cho Suspense hoạt động với việc tìm nạp dữ liệu, bạn cần một lớp:

Trình quản lý tài nguyên này thường được triển khai bằng một cache đơn giản (ví dụ: một Map hoặc một object) để lưu trữ trạng thái của mỗi tài nguyên (đang chờ, đã giải quyết, hoặc lỗi). Mặc dù bạn có thể tự xây dựng nó cho mục đích minh họa, trong một ứng dụng thực tế, bạn sẽ sử dụng một thư viện tìm nạp dữ liệu mạnh mẽ tích hợp với Suspense.

4. Chế độ Đồng thời (Concurrent Mode - Cải tiến của React 18)

Mặc dù Suspense có thể được sử dụng trong các phiên bản React cũ hơn, sức mạnh đầy đủ của nó được giải phóng với Concurrent React (được bật mặc định trong React 18 với createRoot). Chế độ Đồng thời cho phép React ngắt, tạm dừng và tiếp tục công việc render. Điều này có nghĩa là:

Các Mẫu Tìm Nạp Dữ Liệu với Suspense

Hãy cùng khám phá sự tiến hóa của các mẫu tìm nạp dữ liệu với sự ra đời của Suspense.

Mẫu 1: Tìm nạp rồi Render (Fetch-Then-Render - Truyền thống với Suspense)

Đây là cách tiếp cận cổ điển nơi dữ liệu được tìm nạp, và chỉ sau đó component mới được render. Mặc dù không tận dụng cơ chế 'ném promise' trực tiếp cho dữ liệu, bạn có thể bọc một component *cuối cùng* sẽ render dữ liệu trong một ranh giới Suspense để cung cấp một fallback. Điều này thiên về việc sử dụng Suspense như một bộ điều phối UI tải chung cho các component cuối cùng sẽ sẵn sàng, ngay cả khi việc tìm nạp dữ liệu bên trong của chúng vẫn dựa trên useEffect truyền thống.

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

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

  useEffect(() => {
    const fetchUserData = async () => {
      setIsLoading(true);
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();
      setUser(data);
      setIsLoading(false);
    };
    fetchUserData();
  }, [userId]);

  if (isLoading) {
    return <p>Đang tải chi tiết người dùng...</p>;
  }

  return (
    <div>
      <h3>Người dùng: {user.name}</h3>
      <p>Email: {user.email}</p>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Ví dụ Fetch-Then-Render</h1>
      <Suspense fallback={<div>Đang tải trang tổng thể...</div>}>
        <UserDetails userId={"1"} />
      </Suspense>
    </div>
  );
}

Ưu điểm: Dễ hiểu, tương thích ngược. Có thể được sử dụng như một cách nhanh chóng để thêm trạng thái tải toàn cục.

Nhược điểm: Không loại bỏ mã lặp bên trong UserDetails. Vẫn dễ bị thác nước nếu các component tìm nạp dữ liệu tuần tự. Không thực sự tận dụng cơ chế 'ném-và-bắt' của Suspense cho chính dữ liệu.

Mẫu 2: Render rồi Tìm nạp (Render-Then-Fetch - Tìm nạp bên trong Render, không dành cho Production)

Mẫu này chủ yếu để minh họa những gì không nên làm trực tiếp với Suspense, vì nó có thể dẫn đến vòng lặp vô hạn hoặc các vấn đề về hiệu suất nếu không được xử lý cẩn thận. Nó liên quan đến việc cố gắng tìm nạp dữ liệu hoặc gọi một hàm tạm dừng trực tiếp trong pha render của một component, *mà không* có cơ chế caching phù hợp.

// KHÔNG SỬ DỤNG TRONG PRODUCTION MÀ KHÔNG CÓ LỚP CACHING PHÙ HỢP
// Đây hoàn toàn là để minh họa cách một 'throw' trực tiếp có thể hoạt động về mặt khái niệm.

let fetchedData = null;
let dataPromise = null;

function fetchDataSynchronously(url) {
  if (fetchedData) {
    return fetchedData;
  }

  if (!dataPromise) {
    dataPromise = fetch(url)
      .then(res => res.json())
      .then(data => { fetchedData = data; dataPromise = null; return data; })
      .catch(err => { dataPromise = null; throw err; });
  }
  throw dataPromise; // Đây là nơi Suspense hoạt động
}

function UserDetailsBadExample({ userId }) {
  const user = fetchDataSynchronously(`/api/users/${userId}`);
  return (
    <div>
      <h3>Người dùng: {user.name}</h3>
      <p>Email: {user.email}</p>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Render-Then-Fetch (Minh họa, KHÔNG khuyến nghị trực tiếp)</h1>
      <Suspense fallback={<div>Đang tải người dùng...</div>}>
        <UserDetailsBadExample userId={"2"} />
      </Suspense>
    </div>
  );
}

Ưu điểm: Cho thấy cách một component có thể trực tiếp 'yêu cầu' dữ liệu và tạm dừng nếu chưa sẵn sàng.

Nhược điểm: Rất có vấn đề cho môi trường production. Hệ thống fetchedDatadataPromise toàn cục, thủ công này quá đơn giản, không xử lý nhiều yêu cầu, vô hiệu hóa cache, hoặc các trạng thái lỗi một cách mạnh mẽ. Đây là một minh họa sơ khai về khái niệm 'ném-một-promise', không phải là một mẫu để áp dụng.

Mẫu 3: Tìm nạp trong lúc Render (Fetch-As-You-Render - Mẫu Suspense Lý Tưởng)

Đây là sự thay đổi mô hình mà Suspense thực sự mang lại cho việc tìm nạp dữ liệu. Thay vì chờ một component render rồi mới tìm nạp dữ liệu, hoặc tìm nạp tất cả dữ liệu từ đầu, Fetch-As-You-Render có nghĩa là bạn bắt đầu tìm nạp dữ liệu *càng sớm càng tốt*, thường là *trước* hoặc *đồng thời với* quá trình render. Các component sau đó 'đọc' dữ liệu từ một cache, và nếu dữ liệu chưa sẵn sàng, chúng sẽ tạm dừng. Ý tưởng cốt lõi là tách biệt logic tìm nạp dữ liệu khỏi logic render của component.

Để triển khai Fetch-As-You-Render, bạn cần một cơ chế để:

  1. Khởi tạo một lần tìm nạp dữ liệu bên ngoài hàm render của component (ví dụ: khi một route được nhập, hoặc một nút được nhấp).
  2. Lưu trữ promise hoặc dữ liệu đã giải quyết trong một cache.
  3. Cung cấp một cách để các component 'đọc' từ cache này. Nếu dữ liệu chưa có sẵn, hàm đọc sẽ ném ra promise đang chờ xử lý.

Mẫu này giải quyết vấn đề thác nước. Nếu hai component khác nhau cần dữ liệu, các yêu cầu của chúng có thể được khởi tạo song song, và UI sẽ chỉ xuất hiện khi *cả hai* đều sẵn sàng, được điều phối bởi một ranh giới Suspense duy nhất.

Triển khai thủ công (để hiểu)

Để nắm bắt cơ chế cơ bản, hãy tạo một trình quản lý tài nguyên thủ công đơn giản. Trong một ứng dụng thực tế, bạn sẽ sử dụng một thư viện chuyên dụng.

import React, { Suspense } from 'react';

// --- Trình Quản Lý Cache/Tài Nguyên Đơn Giản --- //
const cache = new Map();

function createResource(promise) {
  let status = 'pending';
  let result;
  let suspender = promise.then(
    (r) => {
      status = 'success';
      result = r;
    },
    (e) => {
      status = 'error';
      result = e;
    }
  );

  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    },
  };
}

function fetchData(key, fetcher) {
  if (!cache.has(key)) {
    cache.set(key, createResource(fetcher()));
  }
  return cache.get(key);
}

// --- Các Hàm Tìm Nạp Dữ Liệu --- //
const fetchUserById = (id) => {
  console.log(`Đang tìm nạp người dùng ${id}...`);
  return new Promise(resolve => setTimeout(() => {
    const users = {
      '1': { id: '1', name: 'Alice Smith', email: 'alice@example.com' },
      '2': { id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
      '3': { id: '3', name: 'Charlie Brown', email: 'charlie@example.com' }
    };
    resolve(users[id]);
  }, 1500));
};

const fetchPostsByUserId = (userId) => {
  console.log(`Đang tìm nạp bài viết cho người dùng ${userId}...`);
  return new Promise(resolve => setTimeout(() => {
    const posts = {
      '1': [{ id: 'p1', title: 'Bài Viết Đầu Tiên Của Tôi' }, { id: 'p2', title: 'Những Cuộc Phiêu Lưu Du Lịch' }],
      '2': [{ id: 'p3', title: 'Góc Nhìn Về Lập Trình' }],
      '3': [{ id: 'p4', title: 'Xu Hướng Toàn Cầu' }, { id: 'p5', title: 'Ẩm Thực Địa Phương' }]
    };
    resolve(posts[userId] || []);
  }, 2000));
};

// --- Các Component --- //
function UserProfile({ userId }) {
  const userResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
  const user = userResource.read(); // Sẽ tạm dừng nếu dữ liệu người dùng chưa sẵn sàng

  return (
    <div>
      <h3>Người dùng: {user.name}</h3>
      <p>Email: {user.email}</p>
    </div>
  );
}

function UserPosts({ userId }) {
  const postsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
  const posts = postsResource.read(); // Sẽ tạm dừng nếu dữ liệu bài viết chưa sẵn sàng

  return (
    <div>
      <h4>Bài viết của {userId}:</h4>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
        {posts.length === 0 && <li>Không tìm thấy bài viết nào.</li>}
      </ul>
    </div>
  );
}

// --- Ứng dụng --- //
let initialUserResource = null;
let initialPostsResource = null;

function prefetchDataForUser(userId) {
  initialUserResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
  initialPostsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
}

// Tìm nạp trước một số dữ liệu trước khi component App được render
prefetchDataForUser('1');

function App() {
  return (
    <div>
      <h1>Fetch-As-You-Render với Suspense</h1>
      <p>Điều này minh họa cách tìm nạp dữ liệu có thể diễn ra song song, được điều phối bởi Suspense.</p>

      <Suspense fallback={<div>Đang tải hồ sơ người dùng và bài viết...</div>}>
        <UserProfile userId={"1"} />
        <UserPosts userId={"1"} />
      </Suspense>

      <h2>Một Phần Khác</h2>
      <Suspense fallback={<div>Đang tải người dùng khác...</div>}>
        <UserProfile userId={"2"} />
      </Suspense>
    </div>
  );
}

Trong ví dụ này:

Các thư viện cho Fetch-As-You-Render

Xây dựng và duy trì một trình quản lý tài nguyên mạnh mẽ theo cách thủ công là rất phức tạp. May mắn thay, một số thư viện tìm nạp dữ liệu trưởng thành đã áp dụng hoặc đang áp dụng Suspense, cung cấp các giải pháp đã được kiểm chứng:

Các thư viện này trừu tượng hóa sự phức tạp của việc tạo và quản lý tài nguyên, xử lý caching, xác thực lại, cập nhật lạc quan và xử lý lỗi, giúp việc triển khai Fetch-As-You-Render dễ dàng hơn nhiều.

Mẫu 4: Tìm Nạp Trước (Prefetching) với các Thư viện Hỗ trợ Suspense

Tìm nạp trước là một tối ưu hóa mạnh mẽ nơi bạn chủ động tìm nạp dữ liệu mà người dùng có khả năng cần trong tương lai gần, trước cả khi họ yêu cầu một cách rõ ràng. Điều này có thể cải thiện đáng kể hiệu suất cảm nhận được.

Với các thư viện hỗ trợ Suspense, việc tìm nạp trước trở nên liền mạch. Bạn có thể kích hoạt tìm nạp dữ liệu dựa trên các tương tác của người dùng không làm thay đổi UI ngay lập tức, chẳng hạn như di chuột qua một liên kết hoặc nút.

import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';

// Giả sử đây là các lệnh gọi API của bạn
const fetchProductById = async (id) => {
  console.log(`Đang tìm nạp sản phẩm ${id}...`);
  return new Promise(resolve => setTimeout(() => {
    const products = {
      'A001': { id: 'A001', name: 'Global Widget X', price: 29.99, description: 'Một widget đa năng cho sử dụng quốc tế.' },
      'B002': { id: 'B002', name: 'Universal Gadget Y', price: 149.99, description: 'Thiết bị tiên tiến, được yêu thích trên toàn thế giới.' },
    };
    resolve(products[id]);
  }, 1000));
};

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true, // Bật Suspense cho tất cả các query theo mặc định
    },
  },
});

function ProductDetails({ productId }) {
  const { data: product } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProductById(productId),
  });

  return (
    <div style={{"border": "1px solid #ccc", "padding": "15px", "margin": "10px 0"}}>
      <h3>{product.name}</h3>
      <p>Giá: ${product.price.toFixed(2)}</p>
      <p>{product.description}</p>
    </div>
  );
}

function ProductList() {
  const handleProductHover = (productId) => {
    // Tìm nạp trước dữ liệu khi người dùng di chuột qua liên kết sản phẩm
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProductById(productId),
    });
    console.log(`Đang tìm nạp trước sản phẩm ${productId}`);
  };

  return (
    <div>
      <h2>Sản phẩm có sẵn:</h2>
      <ul>
        <li>
          <a href="#" onMouseEnter={() => handleProductHover('A001')}
             onClick={(e) => { e.preventDefault(); /* Điều hướng hoặc hiển thị chi tiết */ }}
          >Global Widget X (A001)</a>
        </li>
        <li>
          <a href="#" onMouseEnter={() => handleProductHover('B002')}
             onClick={(e) => { e.preventDefault(); /* Điều hướng hoặc hiển thị chi tiết */ }}
          >Universal Gadget Y (B002)</a>
        </li>
      </ul>
      <p>Di chuột qua liên kết sản phẩm để xem prefetching hoạt động. Mở tab network để quan sát.</p>
    </div>
  );
}

function App() {
  const [showProductA, setShowProductA] = React.useState(false);
  const [showProductB, setShowProductB] = React.useState(false);

  return (
    <QueryClientProvider client={queryClient}>
      <h1>Tìm nạp trước với React Suspense (React Query)</h1>
      <ProductList />

      <button onClick={() => setShowProductA(true)}>Hiển thị Global Widget X</button>
      <button onClick={() => setShowProductB(true)}>Hiển thị Universal Gadget Y</button>

      {showProductA && (
        <Suspense fallback={<p>Đang tải Global Widget X...</p>}>
          <ProductDetails productId="A001" />
        </Suspense>
      )}
      {showProductB && (
        <Suspense fallback={<p>Đang tải Universal Gadget Y...</p>}>
          <ProductDetails productId="B002" />
        </Suspense>
      )}
    </QueryClientProvider>
  );
}

Trong ví dụ này, việc di chuột qua một liên kết sản phẩm sẽ kích hoạt `queryClient.prefetchQuery`, bắt đầu quá trình tìm nạp dữ liệu ở chế độ nền. Nếu người dùng sau đó nhấp vào nút để hiển thị chi tiết sản phẩm, và dữ liệu đã có trong cache từ lần tìm nạp trước, component sẽ render ngay lập tức mà không bị tạm dừng. Nếu việc tìm nạp trước vẫn đang diễn ra hoặc chưa được bắt đầu, Suspense sẽ hiển thị fallback cho đến khi dữ liệu sẵn sàng.

Xử Lý Lỗi với Suspense và Ranh Giới Lỗi (Error Boundaries)

Trong khi Suspense xử lý trạng thái 'đang tải' bằng cách hiển thị một fallback, nó không trực tiếp xử lý các trạng thái 'lỗi'. Nếu một promise được ném ra bởi một component đang tạm dừng bị từ chối (tức là tìm nạp dữ liệu thất bại), lỗi này sẽ lan truyền lên cây component. Để xử lý các lỗi này một cách duyên dáng và hiển thị một UI phù hợp, bạn cần sử dụng Ranh Giới Lỗi (Error Boundaries).

Một Ranh Giới Lỗi là một component React triển khai một trong hai phương thức vòng đời componentDidCatch hoặc static getDerivedStateFromError. Nó bắt các lỗi JavaScript ở bất cứ đâu trong cây component con của nó, bao gồm cả các lỗi được ném ra bởi các promise mà Suspense thường sẽ bắt nếu chúng đang chờ xử lý.

import React, { Suspense, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';

// --- Component Ranh Giới Lỗi --- //
class MyErrorBoundary 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 fallback.
    return { hasError: true, 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 một lỗi:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Bạn có thể render bất kỳ UI fallback tùy chỉnh nào
      return (
        <div style={{"border": "2px solid red", "padding": "20px", "margin": "20px 0", "background": "#ffe0e0"}}>
          <h2>Đã có lỗi xảy ra!</h2>
          <p>{this.state.error && this.state.error.message}</p>
          <p>Vui lòng thử làm mới trang hoặc liên hệ hỗ trợ.</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>Thử Lại</button>
        </div>
      );
    }
    return this.props.children;
  }
}

// --- Tìm Nạp Dữ Liệu (có khả năng xảy ra lỗi) --- //
const fetchItemById = async (id) => {
  console.log(`Đang cố gắng tìm nạp mục ${id}...`);
  return new Promise((resolve, reject) => setTimeout(() => {
    if (id === 'error-item') {
      reject(new Error('Không thể tải mục: Mạng không thể truy cập hoặc không tìm thấy mục.'));
    } else if (id === 'slow-item') {
      resolve({ id: 'slow-item', name: 'Được Giao Chậm', data: 'Mục này mất một lúc nhưng đã đến!', status: 'success' });
    } else {
      resolve({ id, name: `Mục ${id}`, data: `Dữ liệu cho mục ${id}` });
    }
  }, id === 'slow-item' ? 3000 : 800));
};

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
      retry: false, // Để minh họa, vô hiệu hóa thử lại để lỗi xuất hiện ngay lập tức
    },
  },
});

function DisplayItem({ itemId }) {
  const { data: item } = useQuery({
    queryKey: ['item', itemId],
    queryFn: () => fetchItemById(itemId),
  });

  return (
    <div>
      <h3>Chi tiết mục:</h3>
      <p>ID: {item.id}</p>
      <p>Tên: {item.name}</p>
      <p>Dữ liệu: {item.data}</p>
    </div>
  );
}

function App() {
  const [fetchType, setFetchType] = useState('normal-item');

  return (
    <QueryClientProvider client={queryClient}>
      <h1>Suspense và Ranh Giới Lỗi</h1>

      <div>
        <button onClick={() => setFetchType('normal-item')}>Tìm Nạp Mục Bình Thường</button>
        <button onClick={() => setFetchType('slow-item')}>Tìm Nạp Mục Chậm</button>
        <button onClick={() => setFetchType('error-item')}>Tìm Nạp Mục Gây Lỗi</button>
      </div>

      <MyErrorBoundary>
        <Suspense fallback={<p>Đang tải mục qua Suspense...</p>}>
          <DisplayItem itemId={fetchType} />
        </Suspense>
      </MyErrorBoundary>
    </QueryClientProvider>
  );
}

Bằng cách bọc ranh giới Suspense của bạn (hoặc các component có thể tạm dừng) bằng một Ranh Giới Lỗi, bạn đảm bảo rằng các lỗi mạng hoặc lỗi máy chủ trong quá trình tìm nạp dữ liệu được bắt và xử lý một cách duyên dáng, ngăn chặn toàn bộ ứng dụng bị sập. Điều này cung cấp một trải nghiệm mạnh mẽ và thân thiện với người dùng, cho phép người dùng hiểu vấn đề và có khả năng thử lại.

Quản Lý Trạng Thái và Vô Hiệu Hóa Dữ Liệu với Suspense

Điều quan trọng cần làm rõ là React Suspense chủ yếu giải quyết trạng thái tải ban đầu của các tài nguyên bất đồng bộ. Nó không vốn có khả năng quản lý cache phía client, xử lý việc vô hiệu hóa dữ liệu, hoặc điều phối các mutation (các hoạt động tạo, cập nhật, xóa) và các cập nhật UI sau đó.

Đây là lúc các thư viện tìm nạp dữ liệu hỗ trợ Suspense (React Query, SWR, Apollo Client, Relay) trở nên không thể thiếu. Chúng bổ sung cho Suspense bằng cách cung cấp:

Nếu không có một thư viện tìm nạp dữ liệu mạnh mẽ, việc triển khai các tính năng này trên một trình quản lý tài nguyên Suspense thủ công sẽ là một công việc đáng kể, về cơ bản yêu cầu bạn phải xây dựng framework tìm nạp dữ liệu của riêng mình.

Những Cân Nhắc Thực Tế và Các Phương Pháp Tốt Nhất

Việc áp dụng Suspense cho tìm nạp dữ liệu là một quyết định kiến trúc quan trọng. Dưới đây là một số cân nhắc thực tế cho một ứng dụng toàn cầu:

1. Không Phải Tất Cả Dữ Liệu Đều Cần Suspense

Suspense lý tưởng cho dữ liệu quan trọng ảnh hưởng trực tiếp đến việc render ban đầu của một component. Đối với dữ liệu không quan trọng, các lần tìm nạp nền, hoặc dữ liệu có thể được tải một cách lười biếng mà không có tác động trực quan mạnh mẽ, useEffect truyền thống hoặc pre-rendering vẫn có thể phù hợp. Việc lạm dụng Suspense có thể dẫn đến một trải nghiệm tải ít chi tiết hơn, vì một ranh giới Suspense duy nhất chờ đợi *tất cả* các con của nó được giải quyết.

2. Độ Chi Tiết của các Ranh Giới Suspense

Hãy đặt các ranh giới <Suspense> của bạn một cách cẩn thận. Một ranh giới lớn duy nhất ở đầu ứng dụng của bạn có thể che giấu toàn bộ trang sau một spinner, điều này có thể gây khó chịu. Các ranh giới nhỏ hơn, chi tiết hơn cho phép các phần khác nhau của trang tải độc lập, cung cấp một trải nghiệm tiến bộ và đáp ứng tốt hơn. Ví dụ, một ranh giới xung quanh component hồ sơ người dùng, và một ranh giới khác xung quanh danh sách các sản phẩm được đề xuất.

<div>
  <h1>Trang Sản Phẩm</h1>
  <Suspense fallback={<p>Đang tải chi tiết sản phẩm chính...</p>}>
    <ProductDetails id="prod123" />
  </Suspense>

  <hr />

  <h2>Sản Phẩm Liên Quan</h2>
  <Suspense fallback={<p>Đang tải các sản phẩm liên quan...</p>}>
    <RelatedProducts category="electronics" />
  </Suspense>
</div>

Cách tiếp cận này có nghĩa là người dùng có thể xem chi tiết sản phẩm chính ngay cả khi các sản phẩm liên quan vẫn đang tải.

3. Render Phía Máy Chủ (SSR) và Streaming HTML

Các API SSR streaming mới của React 18 (renderToPipeableStream) tích hợp đầy đủ với Suspense. Điều này cho phép máy chủ của bạn gửi HTML ngay khi nó sẵn sàng, ngay cả khi các phần của trang (như các component phụ thuộc vào dữ liệu) vẫn đang tải. Máy chủ có thể stream một placeholder (từ fallback của Suspense) và sau đó stream nội dung thực tế khi dữ liệu được giải quyết, mà không yêu cầu một lần render lại hoàn toàn phía client. Điều này cải thiện đáng kể hiệu suất tải cảm nhận được cho người dùng toàn cầu trên các điều kiện mạng khác nhau.

4. Áp Dụng Tăng Dần

Bạn không cần phải viết lại toàn bộ ứng dụng của mình để sử dụng Suspense. Bạn có thể giới thiệu nó một cách tăng dần, bắt đầu với các tính năng hoặc component mới sẽ được hưởng lợi nhiều nhất từ các mẫu tải khai báo của nó.

5. Công Cụ và Gỡ Lỗi

Mặc dù Suspense đơn giản hóa logic component, việc gỡ lỗi có thể khác. React DevTools cung cấp thông tin chi tiết về các ranh giới Suspense và trạng thái của chúng. Hãy làm quen với cách thư viện tìm nạp dữ liệu bạn chọn hiển thị trạng thái nội bộ của nó (ví dụ: React Query Devtools).

6. Thời Gian Chờ cho các Fallback của Suspense

Đối với thời gian tải rất dài, bạn có thể muốn giới thiệu một thời gian chờ cho fallback của Suspense, hoặc chuyển sang một chỉ báo tải chi tiết hơn sau một khoảng trễ nhất định. Các hook useDeferredValueuseTransition trong React 18 có thể giúp quản lý các trạng thái tải tinh tế hơn này, cho phép bạn hiển thị một phiên bản 'cũ' của UI trong khi dữ liệu mới đang được tìm nạp, hoặc trì hoãn các cập nhật không khẩn cấp.

Tương Lai của Việc Tìm Nạp Dữ Liệu trong React: Component React Phía Server và Hơn Nữa

Hành trình tìm nạp dữ liệu trong React không dừng lại ở Suspense phía client. Component React Phía Server (RSC) đại diện cho một sự tiến hóa đáng kể, hứa hẹn sẽ làm mờ ranh giới giữa client và server, và tối ưu hóa hơn nữa việc tìm nạp dữ liệu.

Khi React tiếp tục trưởng thành, Suspense sẽ là một phần ngày càng trung tâm của câu đố để xây dựng các ứng dụng hiệu suất cao, thân thiện với người dùng và dễ bảo trì. Nó thúc đẩy các nhà phát triển hướng tới một cách xử lý các hoạt động bất đồng bộ mang tính khai báo và linh hoạt hơn, chuyển sự phức tạp từ các component riêng lẻ vào một lớp dữ liệu được quản lý tốt.

Kết luận

React Suspense, ban đầu là một tính năng để tách mã, đã phát triển thành một công cụ biến đổi cho việc tìm nạp dữ liệu. Bằng cách áp dụng mẫu Fetch-As-You-Render và tận dụng các thư viện hỗ trợ Suspense, các nhà phát triển có thể cải thiện đáng kể trải nghiệm người dùng của ứng dụng, loại bỏ các thác nước tải, đơn giản hóa logic component và cung cấp các trạng thái tải mượt mà, phối hợp. Kết hợp với Ranh Giới Lỗi để xử lý lỗi mạnh mẽ và lời hứa trong tương lai của Component React Phía Server, Suspense trao quyền cho chúng ta xây dựng các ứng dụng không chỉ hiệu suất và linh hoạt mà còn thú vị hơn cho người dùng trên toàn cầu. Việc chuyển sang một mô hình tìm nạp dữ liệu do Suspense điều khiển đòi hỏi một sự điều chỉnh về mặt khái niệm, nhưng những lợi ích về sự rõ ràng của mã, hiệu suất và sự hài lòng của người dùng là đáng kể và xứng đáng với sự đầu tư.