Bài 29.3: SWR hook trong TypeScript

Chào mừng bạn quay trở lại với series blog về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một chủ đề cực kỳ quan trọng và hữu ích trong việc phát triển các ứng dụng React/Next.js hiện đại: quản lý việc fetch dữ liệu. Việc lấy dữ liệu từ API, xử lý trạng thái loading, error, caching, và cập nhật dữ liệu sau khi thay đổi (mutation) có thể trở nên phức tạp và lặp đi lặp lại. May mắn thay, chúng ta có SWR.

SWR là một thư viện React Hooks để fetch dữ liệu, được tạo ra bởi đội ngũ Vercel (đứng sau Next.js). Tên SWR là viết tắt của Stale-While-Revalidate, một chiến lược caching thông minh. Kết hợp SWR với sức mạnh của TypeScript, chúng ta sẽ có một giải pháp mạnh mẽ và an toàn kiểu (type-safe) để quản lý dữ liệu trong ứng dụng của mình.

SWR Là Gì và Tại Sao Nó Lại Tuyệt Vời?

Trước khi đi vào TypeScript, hãy hiểu SWR là gì. SWR dựa trên chiến lược "stale-while-revalidate". Nó hoạt động như sau:

  1. Khi bạn yêu cầu dữ liệu lần đầu, SWR sẽ fetch dữ liệu từ nguồn (API).
  2. lưu trữ dữ liệu này vào cache.
  3. Lần tới khi bạn yêu cầu dữ liệu tương tự, SWR sẽ ngay lập tức trả về dữ liệu đã lưu trong cache (dữ liệu này có thể đã cũ - "stale").
  4. Đồng thời, SWR sẽ fetch dữ liệu mới từ nguồn trong nền ("revalidate").
  5. Khi dữ liệu mới về, SWR sẽ cập nhật cache và UI của bạn.

Chiến lược này mang lại trải nghiệm người dùng tuyệt vời: ứng dụng phản hồi nhanh chóng bằng cách hiển thị dữ liệu cũ ngay lập tức, đồng thời đảm bảo dữ liệu luôn được cập nhật trong nền mà không chặn giao diện người dùng.

Các lợi ích chính khi sử dụng SWR:

  • Caching Tự Động: Giảm thiểu số lần fetch không cần thiết, tăng tốc độ tải trang.
  • Revalidation Thông Minh: Tự động cập nhật dữ liệu khi có thay đổi (ví dụ: khi người dùng focus lại tab trình duyệt, hoặc kết nối mạng hoạt động trở lại).
  • Xử lý Loading & Error: Cung cấp trạng thái isLoadingerror một cách dễ dàng.
  • Tối ưu Hiệu năng: Hỗ trợ deduplication (tránh fetch cùng một dữ liệu nhiều lần), pagination, infinite loading...
  • Hỗ trợ TypeScript Xuất Sắc: Đây là điểm chúng ta sẽ tập trung. TypeScript giúp đảm bảo bạn làm việc với dữ liệu có cấu trúc rõ ràng, tránh lỗi runtime liên quan đến kiểu dữ liệu.

Bắt Đầu Với SWR (Vẫn Dùng TypeScript Nhé!)

Để sử dụng SWR, bạn cần cài đặt nó:

npm install swr typescript @types/react
# hoặc
yarn add swr typescript @types/react

Cú pháp cơ bản của useSWR là:

import useSWR from 'swr';

const { data, error, isLoading } = useSWR(key, fetcher, options);
  • key: Một chuỗi duy nhất (thường là URL của API) hoặc một mảng chứa URL và các tham số. SWR sử dụng key để xác định dữ liệu nào đang được cache và fetch. Nếu keynull hoặc undefined, SWR sẽ không fetch.
  • fetcher: Một hàm Promise nhận key làm tham số và trả về dữ liệu. Đây là nơi bạn thực hiện logic gọi API của mình (ví dụ: dùng fetch hoặc axios).
  • options: Một đối tượng tùy chọn để cấu hình hành vi của SWR (ví dụ: revalidateOnFocus, dedupingInterval, v.v.).
  • data: Dữ liệu trả về từ fetcher sau khi fetch thành công.
  • error: Đối tượng lỗi nếu quá trình fetch thất bại.
  • isLoading: Một boolean cho biết quá trình fetch dữ liệu ban đầu có đang diễn ra hay không. (Trước đây là isValidating cho cả lần fetch ban đầu và revalidation, giờ isLoading chỉ cho lần đầu, isValidating cho tất cả các lần fetch).

Hãy xem một ví dụ cơ bản với TypeScript:

// api.ts (hoặc một file fetcher riêng)
export async function fetchJson<JSON = any>(
  input: RequestInfo,
  init?: RequestInit
): Promise<JSON> {
  const res = await fetch(input, init);
  if (!res.ok) {
    const error = new Error('An error occurred while fetching the data.');
    // Attach extra info to the error object.
    // error.info = await res.json(); // Có thể thêm thông tin lỗi chi tiết
    error.message = res.statusText;
    throw error;
  }
  return res.json();
}

// components/UserProfile.tsx
import React from 'react';
import useSWR from 'swr';
import { fetchJson } from '../api'; // Import fetcher của bạn

// Định nghĩa kiểu dữ liệu mong muốn
interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: number }) {
  // Sử dụng useSWR với kiểu dữ liệu
  // key có thể là null hoặc undefined nếu userId chưa có, giúp bỏ qua fetch
  const { data: user, error, isLoading } = useSWR<User, Error>(
    userId ? `/api/users/${userId}` : null, // key phụ thuộc vào userId
    fetchJson // hàm fetcher của bạn
  );

  if (isLoading) {
    return <div>Đang tải thông tin người dùng...</div>;
  }

  if (error) {
    // error được tự động typed là Error nhờ kiểu dữ liệu thứ 2 trong useSWR<User, Error>
    return <div>Lỗi tải thông tin người dùng: {error.message}</div>;
  }

  // data được tự động typed là User
  if (!user) {
      // Điều này có thể xảy ra nếu key ban đầu là null hoặc undefined
      return <div>Chọn một người dùng để xem.</div>;
  }

  return (
    <div>
      <h2>Thông tin người dùng</h2>
      <p>ID: {user.id}</p>
      <p>Tên: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  );
}

export default UserProfile;

Giải thích Code:

  1. Chúng ta định nghĩa một interface User để mô tả cấu trúc dữ liệu mà API trả về. Điều này cực kỳ quan trọng khi làm việc với TypeScript và SWR.
  2. Hàm fetchJson là một fetcher đơn giản sử dụng fetch API. Nó được typed để trả về một Promise<JSON> nơi JSON là kiểu dữ liệu mong muốn. Chúng ta cũng thêm logic kiểm tra res.ok để xử lý lỗi.
  3. Trong component UserProfile, chúng ta gọi useSWR<User, Error>(...).
    • User: Kiểu dữ liệu cho data trả về thành công.
    • Error: Kiểu dữ liệu cho error nếu quá trình fetch thất bại.
  4. key/api/users/${userId}. Chúng ta sử dụng toán tử ba ngôi userId ? ... : null để đảm bảo SWR chỉ fetch khi userId có giá trị (khác null/undefined).
  5. fetchJson được truyền vào làm fetcher.
  6. Kết quả trả về là data, error, và isLoading. Nhờ TypeScript, data sẽ có kiểu User | undefined, error có kiểu Error | undefined, và isLoadingboolean.
  7. Chúng ta sử dụng isLoading, error, và user để hiển thị giao diện tương ứng (đang tải, lỗi, hoặc dữ liệu người dùng). TypeScript giúp chúng ta biết chắc chắn user sẽ có các thuộc tính id, name, email khi nó không phải là undefined.

Xử Lý Trạng Thái Loading và Error Chi Tiết Hơn

SWR cung cấp isLoading (cho lần fetch đầu tiên) và isValidating (cho mọi lần fetch, bao gồm revalidation) để giúp bạn quản lý trạng thái tải. error chứa thông tin lỗi.

// components/ArticleList.tsx
import React from 'react';
import useSWR from 'swr';
import { fetchJson } from '../api';

interface Article {
  id: number;
  title: string;
  summary: string;
}

interface ArticlesResponse {
  articles: Article[];
  total: number;
}

function ArticleList() {
  // Định nghĩa key và fetcher, type cho dữ liệu thành công và lỗi
  const { data, error, isLoading, isValidating, mutate } = useSWR<ArticlesResponse, Error>(
    '/api/articles',
    fetchJson,
    {
      // Tùy chọn cấu hình SWR
      revalidateOnFocus: true, // Tự động fetch lại khi focus vào tab
      // revalidateIfStale: false, // Ngăn fetch lại nếu dữ liệu cache vẫn còn
      // dedupingInterval: 5000 // Chỉ fetch tối đa 1 lần trong 5 giây cho cùng key
    }
  );

  // Kiểm tra trạng thái loading ban đầu
  if (isLoading) {
    return <div><p>Đang tải danh sách bài viết...</p></div>;
  }

  // Kiểm tra trạng thái lỗi
  if (error) {
    return <div><p>Không thể tải danh sách bài viết: {error.message}</p></div>;
  }

  // Khi dữ liệu đã tải xong
  // TypeScript biết data có kiểu ArticlesResponse | undefined
  // Vì đã check error và isLoading, data chắc chắn không phải undefined ở đây
  const articles = data?.articles || []; // Sử dụng optional chaining và fallback

  // Bạn có thể hiển thị trạng thái revalidating (đang cập nhật dữ liệu trong nền)
  const revalidatingIndicator = isValidating && !isLoading ? (
      <small>(Đang cập nhật...)</small>
  ) : null;


  return (
    <div>
      <h1>Danh sách bài viết {revalidatingIndicator}</h1>
      {articles.length === 0 && !isLoading && !error ? (
          <p>Không  bài viết nào được tìm thấy.</p>
      ) : (
           <ul>
            {articles.map(article => (
              <li key={article.id}>
                <h3>{article.title}</h3> {/* TypeScript đảm bảo article  title */}
                <p>{article.summary}</p>
              </li>
            ))}
          </ul>
      )}

    </div>
  );
}

export default ArticleList;

Giải thích Code:

  1. Chúng ta định nghĩa các interface ArticleArticlesResponse.
  2. useSWR<ArticlesResponse, Error> đảm bảo dataArticlesResponse | undefinederrorError | undefined.
  3. Chúng ta xử lý các trường hợp isLoadingerror trước khi render danh sách bài viết.
  4. isValidating cho phép chúng ta hiển thị một indicator nhỏ khi SWR đang fetch dữ liệu trong nền (revalidation).
  5. Khi data đã có, TypeScript biết cấu trúc của nó, cho phép chúng ta truy cập data.articles một cách an toàn (hoặc dùng optional chaining data?.articles).

Fetch Dữ Liệu Phụ Thuộc và Dynamic Keys

Rất nhiều trường hợp bạn cần fetch dữ liệu phụ thuộc vào một ID, một tham số URL, hoặc trạng thái khác của component. SWR xử lý điều này rất tốt bằng cách sử dụng key động. key có thể là một hàm hoặc một mảng. Khi bất kỳ phần tử nào trong mảng key thay đổi, SWR sẽ tự động fetch lại.

// components/PostDetails.tsx
import React from 'react';
import { useRouter } from 'next/router'; // Ví dụ dùng Next.js router
import useSWR from 'swr';
import { fetchJson } from '../api';

interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
}

interface Author {
    id: number;
    name: string;
}

// Key có thể là mảng chứa URL và ID
type PostKey = ['/api/posts', number] | null;
type AuthorKey = ['/api/users', number] | null;


function PostDetails() {
  const router = useRouter();
  const postId = router.query.postId ? Number(router.query.postId) : undefined; // Lấy postId từ URL

  // Fetch bài viết
  const postKey: PostKey = postId ? ['/api/posts', postId] : null;
  const { data: post, error: postError, isLoading: isPostLoading } = useSWR<Post, Error>(
      postKey,
      ([url, id]) => fetchJson(`${url}/${id}`) // fetcher nhận mảng key
  );

  // Fetch thông tin tác giả (phụ thuộc vào post)
  const authorId = post?.authorId; // authorId chỉ có khi post đã được tải
  const authorKey: AuthorKey = authorId ? ['/api/users', authorId] : null;
  const { data: author, error: authorError, isLoading: isAuthorLoading } = useSWR<Author, Error>(
      authorKey,
      ([url, id]) => fetchJson(`${url}/${id}`) // fetcher nhận mảng key
  );

  const isLoading = isPostLoading || isAuthorLoading;
  const error = postError || authorError;

  if (isLoading) {
    return <div>Đang tải chi tiết bài viết...</div>;
  }

  if (error) {
    return <div>Lỗi tải bài viết: {error.message}</div>;
  }

  if (!post) {
      // Có thể xảy ra nếu postId ban đầu không hợp lệ hoặc fetch lỗi nhưng không có error object
      return <div>Bài viết không tồn tại hoặc đã xảy ra lỗi không xác định.</div>;
  }

  return (
    <div>
      <h1>{post.title}</h1> {/* TypeScript biết post  title */}
      {author ? (
          <p>Tác giả: {author.name}</p> // TypeScript biết author  name
      ) : (
          <p>Đang tải thông tin tác giả...</p>
      )}
      <p>{post.body}</p>
    </div>
  );
}

export default PostDetails;

Giải thích Code:

  1. Chúng ta lấy postId từ router.
  2. postKey được tạo dưới dạng một mảng ['/api/posts', postId]. SWR sẽ theo dõi sự thay đổi của cả hai phần tử trong mảng này. Nếu postId thay đổi, postKey thay đổi, và SWR sẽ fetch lại dữ liệu bài viết. Nếu postIdundefined, postKeynull, và SWR sẽ không fetch.
  3. fetcher cho bài viết nhận [url, id] từ mảng key và xây dựng URL hoàn chỉnh.
  4. authorId chỉ được lấy sau khi post đã được fetch thành công (post?.authorId).
  5. authorKey cũng là một mảng phụ thuộc vào authorId. SWR sẽ chỉ fetch thông tin tác giả sau khi authorId có giá trị (tức là sau khi bài viết đã tải xong). SWR tự động quản lý sự phụ thuộc này.
  6. Chúng ta kết hợp các trạng thái loading và error từ cả hai hook useSWR.
  7. TypeScript giúp đảm bảo rằng khi chúng ta truy cập post.title hoặc author.name, các đối tượng postauthor đã có cấu trúc dữ liệu chính xác (hoặc chúng ta đã kiểm tra điều kiện !post hay !author).

Cập Nhật Dữ Liệu với mutate

Một trong những tính năng mạnh mẽ của SWR là khả năng cập nhật dữ liệu trong cache và kích hoạt revalidation bằng hàm mutate. Hook useSWR trả về hàm mutate.

Bạn có thể dùng mutate theo nhiều cách:

  1. mutate(): Kích hoạt revalidation cho key hiện tại. SWR sẽ fetch lại dữ liệu trong nền.
  2. mutate(newData, false): Cập nhật cache với newData ngay lập tứckhông kích hoạt revalidation. Hữu ích cho việc cập nhật UI tức thì sau một hành động (optimistic update).
  3. mutate(newData, true): Cập nhật cache với newData ngay lập tức kích hoạt revalidation để xác nhận dữ liệu mới từ server.

Ví dụ về việc "Like" một bài viết và cập nhật số lượt like:

// Giả định API endpoint: POST /api/posts/{postId}/like để tăng like
// Trả về số lượt like mới

// components/LikeButton.tsx
import React from 'react';
import useSWR from 'swr';
import { fetchJson } from '../api';

interface PostLikes {
  likes: number;
}

// Hàm fetcher cho số lượt like
const fetchLikes = async (url: string) => {
    const data = await fetchJson<PostLikes>(url);
    return data.likes; // Chỉ trả về số likes
}

// Hàm để gửi request like lên API
const sendLike = async (postId: number): Promise<number> => {
    // Giả định API trả về số like mới sau khi thành công
    const response = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
    if (!response.ok) {
        throw new Error('Failed to like post');
    }
    const data: PostLikes = await response.json();
    return data.likes;
};


function LikeButton({ postId }: { postId: number }) {
  // Key cho số lượt like của bài viết cụ thể
  const likesKey = `/api/posts/${postId}/likes`;
  const { data: likes, error, isLoading, mutate } = useSWR<number, Error>(
    likesKey,
    fetchLikes // Sử dụng fetcher chỉ lấy số like
  );

  const handleLike = async () => {
    // Bắt đầu optimistic update: Cập nhật UI ngay lập tức với số like tăng thêm 1
    // mutate(newLikes, false) cập nhật cache và UI, không fetch lại
    // currentLikes là giá trị hiện tại trong cache, mặc định là undefined nếu chưa có
    const currentLikes = likes;
    // Đặt trạng thái loading hoặc disabled nút bấm nếu cần ở đây

    // Nếu có dữ liệu cache, cập nhật tạm thời lên UI
    if (currentLikes !== undefined) {
         mutate(currentLikes + 1, false);
    }


    try {
      // Gửi request like lên API
      const newLikes = await sendLike(postId);

      // Sau khi API thành công, cập nhật cache với dữ liệu thật từ API
      // mutate(newLikes, true) cập nhật cache VÀ fetch lại để xác nhận
      // Việc fetch lại có thể cần thiết nếu logic server phức tạp hơn việc chỉ tăng 1
      // hoặc nếu optimistic update có thể sai lệch.
      // Nếu chỉ đơn giản là số like tăng 1 và bạn tin tưởng optimistic update,
      // có thể không cần fetch lại (chỉ cần mutate(newLikes, false) lần nữa hoặc bỏ qua).
      // Trong ví dụ này, ta dùng revalidation (true) để chắc chắn.
       mutate(newLikes, true); // Cập nhật cache và kích hoạt revalidation

    } catch (err) {
      // Nếu API thất bại, rollback lại dữ liệu cache ban đầu (trước optimistic update)
      // mutate(currentLikes, false) cập nhật cache trở lại giá trị ban đầu, không fetch lại
       if (currentLikes !== undefined) {
           mutate(currentLikes, false);
       }
       alert(`Lỗi khi like bài viết: ${err instanceof Error ? err.message : 'Unknown error'}`); // Thông báo lỗi
    }
  };

  if (isLoading) return <button disabled>Loading Likes...</button>;
  if (error) return <button disabled>Error Loading Likes</button>;

  // TypeScript biết likes có kiểu number | undefined
  const displayLikes = likes !== undefined ? likes : 0; // Default về 0 nếu chưa tải xong hoặc lần đầu

  return (
    <button onClick={handleLike} disabled={isLoading || likes === undefined}>
      Like ({displayLikes})
    </button>
  );
}

export default LikeButton;

Giải thích Code:

  1. Chúng ta fetch số lượt like ban đầu bằng useSWR<number, Error>(...).
  2. Interface PostLikes định nghĩa cấu trúc dữ liệu từ API. fetchLikes trích xuất likes và trả về number.
  3. Hàm sendLike mô phỏng việc gọi API POST để tăng like.
  4. Hàm handleLike là nơi xử lý logic khi người dùng click nút.
  5. Optimistic Update: Ngay lập tức cập nhật UI với số like tăng 1 (mutate(currentLikes + 1, false)). Điều này làm cho ứng dụng phản hồi nhanh chóng, người dùng thấy số like tăng lên ngay lập tức.
  6. Sau đó, gọi sendLike để thực sự gửi request lên server.
  7. Nếu request thành công, chúng ta lấy số like mới từ server (newLikes) và gọi mutate(newLikes, true). true ở đây kích hoạt revalidation, đảm bảo dữ liệu hiển thị là chính xác từ server, đề phòng trường hợp optimistic update có sai sót.
  8. Nếu request thất bại, chúng ta rollback lại UI bằng cách gọi mutate(currentLikes, false), khôi phục số like về giá trị trước khi optimistic update.
  9. TypeScript đảm bảo chúng ta làm việc với kiểu number cho số lượt like và kiểu Error cho lỗi.

Global Configuration với SWRConfig

Bạn có thể thiết lập cấu hình SWR mặc định (như fetcher mặc định, các tùy chọn revalidation chung) bằng cách sử dụng component SWRConfig. Điều này rất hữu ích để tránh lặp lại fetcher trong mỗi lần gọi useSWR.

// pages/_app.tsx (trong Next.js) hoặc root component của ứng dụng React
import type { AppProps } from 'next/app';
import { SWRConfig } from 'swr';
import { fetchJson } from '../api'; // Sử dụng fetcher chung

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <SWRConfig
      value={{
        // Thiết lập fetcher mặc định
        fetcher: fetchJson,
        // Các tùy chọn SWR mặc định khác
        revalidateOnFocus: true,
        revalidateIfStale: true,
        // errorRetryInterval: 10000, // Thử lại sau 10 giây nếu lỗi
      }}
    >
      <Component {...pageProps} />
    </SWRConfig>
  );
}

export default MyApp;

Khi fetcher mặc định đã được thiết lập bằng SWRConfig, bạn có thể gọi useSWR mà không cần truyền fetcher:

// components/MyComponent.tsx
import useSWR from 'swr';

interface Data {
  message: string;
}

function MyComponent() {
  // Không cần truyền fetcher vì đã cấu hình global
  const { data, error } = useSWR<Data, Error>('/api/some-data');

  if (error) return <div>Failed to load</div>;
  if (!data) return <div>Loading...</div>;

  return <div>{data.message}</div>;
}

Tuyệt vời phải không? TypeScript vẫn hoạt động hoàn hảo, và code của bạn trở nên gọn gàng hơn.

Tóm Lược và Lời Khuyên

SWR là một hook fetch dữ liệu mạnh mẽ giúp đơn giản hóa việc quản lý cache, loading, error và revalidation trong ứng dụng React/Next.js. Khi kết hợp với TypeScript, bạn không chỉ tận dụng được sức mạnh của SWR mà còn có thêm lớp an toàn kiểu, giảm thiểu lỗi và cải thiện trải nghiệm phát triển.

Một vài lời khuyên khi sử dụng SWR với TypeScript:

  • Luôn Định Nghĩa Kiểu Dữ Liệu: Dành thời gian tạo interface hoặc type cho cấu trúc dữ liệu mà API của bạn trả về. Điều này là xương sống của việc sử dụng SWR hiệu quả với TypeScript.
  • Type Cho useSWR: Cung cấp kiểu dữ liệu cho cả dataerror trong generic type của useSWR<DataType, ErrorType>.
  • Type Cho Fetcher: Đảm bảo hàm fetcher của bạn được typed đúng để trả về Promise<DataType>.
  • Quản Lý key: Sử dụng key động (chuỗi hoặc mảng) để kích hoạt fetch lại khi dependencies thay đổi. Sử dụng null hoặc undefined làm key để bỏ qua việc fetch.
  • Leverage mutate: Sử dụng hàm mutate để cập nhật UI tức thì (optimistic update) và/hoặc kích hoạt revalidation sau các hành động thay đổi dữ liệu (POST, PUT, DELETE).
  • Global Config: Sử dụng SWRConfig để thiết lập fetcher mặc định và các tùy chọn chung, làm cho code gọn gàng hơn.

Việc nắm vững SWR và cách tích hợp nó với TypeScript sẽ giúp bạn xây dựng các ứng dụng front-end nhanh hơn, ổn định hơn và dễ bảo trì hơn. Hãy thử nghiệm và áp dụng nó vào các dự án của bạn!

Comments

There are no comments at the moment.