Trong lập trình React truyền thống, việc gọi API thường gắn liền với bộ ba: useEffect, useState(data) và useState(loading). Cách làm này dẫn đến hàng loạt vấn đề: code lặp đi lặp lại (*boilerplate*), không có cơ chế lưu bộ nhớ đệm (*caching*), dễ bị hiện tượng race-condition, và phải refetch dữ liệu thủ công mỗi khi thao tác CRUD.
TanStack Query (trước đây là React Query) là thư viện quản lý Server State (trạng thái dữ liệu bất đồng bộ) chuẩn công nghiệp hàng đầu hiện nay. Khi kết hợp với Next.js App Router, TanStack Query mang lại:
- Tự động Caching & Deduplication: Loại bỏ triệt để các request mạng trùng lặp trên cùng một trang.
- Background Refetching & Stale-While-Revalidate: Giao diện hiển thị ngay dữ liệu cache có sẵn và tự động cập nhật ngầm khi dữ liệu cũ.
- Tự động Invalidate Cache: Tự động làm mới bảng dữ liệu ngay sau khi thực hiện Mutation (Create/Update/Delete).
- Hỗ trợ Server Component Prefetching: Fetch dữ liệu trước từ server và truyền sang client mượt mà không bị màn hình trắng (*No Loading Flicker*).
Bài viết này sẽ hướng dẫn bạn từ khâu cài đặt, thiết lập Provider an toàn trên môi trường SSR cho tới các kỹ thuật nâng cao với useQuery, useMutation và HydrationBoundary.
1. Cài đặt các thư viện cần thiết
Cài đặt TanStack Query v5 và công cụ gỡ lỗi trực quan DevTools bằng package manager bạn đang dùng:
# Dùng pnpm (khuyên dùng)
pnpm add @tanstack/react-query
pnpm add -D @tanstack/react-query-devtools
# Hoặc dùng npm / yarn / bun
# npm install @tanstack/react-query && npm install -D @tanstack/react-query-devtools
# yarn add @tanstack/react-query && yarn add -D @tanstack/react-query-devtools2. Thiết lập QueryProvider chuẩn SSR trong Next.js App Router
Trong Next.js App Router, ứng dụng render trên cả Server và Client.
Cảnh báo quan trọng: Tuyệt đối không khởi tạo const queryClient = new QueryClient() ở phạm vi toàn cục (global scope) ngoài component, vì điều này sẽ làm rò rỉ dữ liệu giữa các người dùng khác nhau trên máy chủ.
Hãy tạo component QueryProvider.tsx sử dụng cơ chế Singleton an toàn:
"use client";
import {
QueryClient,
QueryClientProvider,
isServer,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Dữ liệu được coi là mới trong 1 phút (tránh refetch liên tục)
staleTime: 60 * 1000,
// Tắt tự động refetch khi chuyển tab để tiết kiệm request
refetchOnWindowFocus: false,
// Thử lại 1 lần nếu network gặp sự cố
retry: 1,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (isServer) {
// Server: Luôn tạo một queryClient mới cho mỗi request
return makeQueryClient();
} else {
// Browser: Tạo một singleton client duy nhất nếu chưa có
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
{/* DevTools chỉ hiển thị ở môi trường Development */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}3. Lấy dữ liệu mượt mà với useQuery
Tách biệt hàm gọi API thuần và Custom Hook useQuery để code rõ ràng và tái sử dụng tối đa:
"use client";
import { useQuery } from "@tanstack/react-query";
// 1. Interface dữ liệu
export interface Post {
id: string;
title: string;
slug: string;
views: number;
}
// 2. Hàm gọi API thuần túy
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("/api/blog/posts");
if (!res.ok) throw new Error("Không thể tải danh sách bài viết");
return res.json();
}
async function fetchPostDetail(slug: string): Promise<Post> {
const res = await fetch(`/api/blog/posts/${slug}`);
if (!res.ok) throw new Error("Không tìm thấy bài viết");
return res.json();
}
// 3. Custom Hook useQuery
export function usePostsQuery() {
return useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000, // Dữ liệu client cache trong 5 phút
});
}
export function usePostDetailQuery(slug: string) {
return useQuery({
queryKey: ["post", slug],
queryFn: () => fetchPostDetail(slug),
enabled: Boolean(slug), // Chỉ chạy query khi có slug
});
}Facing similar challenges?
I can help you optimize your website — reach out for a free consultation.
4. Sử dụng trong Component: Tạm biệt useEffect & useState
Khi dùng trong giao diện, bạn chỉ cần gọi hook một dòng duy nhất:
"use client";
import { usePostsQuery } from "@/src/services/blog/useBlogQueries";
export function PostList() {
const { data: posts, isLoading, isError, error, refetch } = usePostsQuery();
if (isLoading) {
return <div className="p-4 text-zinc-400">Đang tải bài viết...</div>;
}
if (isError) {
return (
<div className="p-4 text-red-400">
Lỗi: {error.message}
<button onClick={() => refetch()} className="ml-2 underline">Thử lại</button>
</div>
);
}
return (
<div className="grid gap-4">
{posts?.map((post) => (
<div key={post.id} className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
<h3 className="font-semibold text-zinc-100">{post.title}</h3>
<span className="text-xs text-zinc-500">{post.views} lượt xem</span>
</div>
))}
</div>
);
}5. Thao tác Dữ liệu (Create/Update/Delete) với useMutation
useMutation được sử dụng cho các thao tác làm thay đổi dữ liệu (POST, PUT, DELETE).
Điểm mạnh nhất của useMutation là khả năng tự động làm mới cache (Invalidate Queries) ngay khi request thành công, giúp bảng danh sách tự động cập nhật dữ liệu mới nhất mà không cần tải lại trang:
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface CreatePostPayload {
title: string;
content: string;
}
export function useCreatePostMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: CreatePostPayload) => {
const res = await fetch("/api/admin/blog", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error("Tạo bài viết thất bại");
return res.json();
},
onSuccess: () => {
// ⚡ Làm mới cache của danh sách bài viết
queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["adminPosts"] });
},
onError: (err) => {
console.error("Lỗi:", err.message);
},
});
}6. Kỹ thuật Server Prefetching với HydrationBoundary
Trong Next.js App Router, để trang web đạt chuẩn SEO và người dùng không phải nhìn thấy màn hình loading khi vừa vào trang, bạn có thể Prefetch dữ liệu ngay trên Server Component (page.tsx) và truyền cache sang Client Component thông qua HydrationBoundary:
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { PostList } from "@/src/features/blog/components/PostList";
// Server Component
export default async function BlogPage() {
const queryClient = new QueryClient();
// 1. Fetch dữ liệu trước trên Server
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("https://api.duynoa.com/api/blog/posts");
return res.json();
},
});
return (
// 2. Hydrate dữ liệu sang Client Component
<HydrationBoundary state={dehydrate(queryClient)}>
<main className="container mx-auto py-12">
<h1 className="text-3xl font-bold text-zinc-100 mb-6">Blog Công Nghệ</h1>
{/* PostList ở client sẽ nhận data ngay lập tức mà không cần loading */}
<PostList />
</main>
</HydrationBoundary>
);
}Kết luận
TanStack Query giải quyết triệt để bài toán quản lý trạng thái dữ liệu bất đồng bộ trong các ứng dụng web hiện đại. Khi kết hợp với kiến trúc Next.js App Router, bạn vừa có được tốc độ render tức thì từ Server Components, vừa duy trì trải nghiệm tương tác mượt mà, realtime và tự động đồng bộ cache ở phía Client.
Want to discuss further?
If you have specific questions about web performance, React, or want to collaborate on a project — reach out directly.