Bài 29.3: SWR hook trong TypeScript

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:
- Khi bạn yêu cầu dữ liệu lần đầu, SWR sẽ fetch dữ liệu từ nguồn (API).
- Nó lưu trữ dữ liệu này vào cache.
- 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").
- Đồng thời, SWR sẽ fetch dữ liệu mới từ nguồn trong nền ("revalidate").
- 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
isLoading
vàerror
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ụngkey
để xác định dữ liệu nào đang được cache và fetch. Nếukey
lànull
hoặcundefined
, SWR sẽ không fetch.fetcher
: Một hàmPromise
nhậnkey
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ùngfetch
hoặcaxios
).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:
- 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. - Hàm
fetchJson
là một fetcher đơn giản sử dụngfetch
API. Nó được typed để trả về mộtPromise<JSON>
nơiJSON
là kiểu dữ liệu mong muốn. Chúng ta cũng thêm logic kiểm trares.ok
để xử lý lỗi. - Trong component
UserProfile
, chúng ta gọiuseSWR<User, Error>(...)
.User
: Kiểu dữ liệu chodata
trả về thành công.Error
: Kiểu dữ liệu choerror
nếu quá trình fetch thất bại.
key
là/api/users/${userId}
. Chúng ta sử dụng toán tử ba ngôiuserId ? ... : null
để đảm bảo SWR chỉ fetch khiuserId
có giá trị (khác null/undefined).fetchJson
được truyền vào làmfetcher
.- Kết quả trả về là
data
,error
, vàisLoading
. Nhờ TypeScript,data
sẽ có kiểuUser | undefined
,error
có kiểuError | undefined
, vàisLoading
làboolean
. - 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ắnuser
sẽ có các thuộc tínhid
,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 có 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 có title */}
<p>{article.summary}</p>
</li>
))}
</ul>
)}
</div>
);
}
export default ArticleList;
Giải thích Code:
- Chúng ta định nghĩa các interface
Article
vàArticlesResponse
. useSWR<ArticlesResponse, Error>
đảm bảodata
làArticlesResponse | undefined
vàerror
làError | undefined
.- Chúng ta xử lý các trường hợp
isLoading
vàerror
trước khi render danh sách bài viết. 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).- Khi
data
đã có, TypeScript biết cấu trúc của nó, cho phép chúng ta truy cậpdata.articles
một cách an toàn (hoặc dùng optional chainingdata?.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 có title */}
{author ? (
<p>Tác giả: {author.name}</p> // TypeScript biết author có 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:
- Chúng ta lấy
postId
từ router. 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ếupostId
thay đổi,postKey
thay đổi, và SWR sẽ fetch lại dữ liệu bài viết. NếupostId
làundefined
,postKey
lànull
, và SWR sẽ không fetch.fetcher
cho bài viết nhận[url, id]
từ mảngkey
và xây dựng URL hoàn chỉnh.authorId
chỉ được lấy sau khipost
đã được fetch thành công (post?.authorId
).authorKey
cũng là một mảng phụ thuộc vàoauthorId
. SWR sẽ chỉ fetch thông tin tác giả sau khiauthorId
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.- Chúng ta kết hợp các trạng thái loading và error từ cả hai hook
useSWR
. - TypeScript giúp đảm bảo rằng khi chúng ta truy cập
post.title
hoặcauthor.name
, các đối tượngpost
vàauthor
đã 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:
mutate()
: Kích hoạt revalidation chokey
hiện tại. SWR sẽ fetch lại dữ liệu trong nền.mutate(newData, false)
: Cập nhật cache vớinewData
ngay lập tức và khô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).mutate(newData, true)
: Cập nhật cache vớinewData
ngay lập tức và 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:
- Chúng ta fetch số lượt like ban đầu bằng
useSWR<number, Error>(...)
. - Interface
PostLikes
định nghĩa cấu trúc dữ liệu từ API.fetchLikes
trích xuấtlikes
và trả vềnumber
. - Hàm
sendLike
mô phỏng việc gọi API POST để tăng like. - Hàm
handleLike
là nơi xử lý logic khi người dùng click nút. - 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. - Sau đó, gọi
sendLike
để thực sự gửi request lên server. - Nếu request thành công, chúng ta lấy số like mới từ server (
newLikes
) và gọimutate(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. - 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. - TypeScript đảm bảo chúng ta làm việc với kiểu
number
cho số lượt like và kiểuError
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ảdata
vàerror
trong generic type củauseSWR<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ụngkey
động (chuỗi hoặc mảng) để kích hoạt fetch lại khi dependencies thay đổi. Sử dụngnull
hoặcundefined
làmkey
để bỏ qua việc fetch. - Leverage
mutate
: Sử dụng hàmmutate
để 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