•21 min read

TanStack Query v5 với Next.js 15: Cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm & Server Actions

TanStack Query v5 với Next.js 15: Cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm & Server Actions

Hướng dẫn này trình bày chi tiết việc tích hợp TanStack Query v5 với Next.js 15 App Router và Server Actions, tập trung vào các mẫu nâng cao cho UI lạc quan (optimistic UI), đồng bộ hóa bộ nhớ đệm và tìm nạp dữ liệu hiệu quả. Chúng ta sẽ đề cập đến các cập nhật lạc quan bền vững với khả năng hoàn tác tự động, khử/khôi phục trạng thái phía máy chủ và triển khai cuộn vô hạn mạnh mẽ.

Audio Briefing
0:00 / 0:00

Tổng quan kiến trúc

Next.js 15 App Router và Server Actions giới thiệu một sự thay đổi mô hình trong việc tìm nạp và thay đổi dữ liệu. Server Actions cho phép gọi trực tiếp các hàm phía máy chủ từ các thành phần client, đơn giản hóa việc thay đổi dữ liệu. Ngược lại, TanStack Query quản lý việc tìm nạp, lưu trữ và đồng bộ hóa dữ liệu phía client. Thách thức nằm ở việc điều phối hai hệ thống này để cung cấp trải nghiệm người dùng nhất quán, hiệu suất cao, đặc biệt khi xử lý các thay đổi ảnh hưởng đến dữ liệu dùng chung.

Nguyên tắc cốt lõi là tận dụng Server Actions cho các thay đổi và revalidatePath/revalidateTag để vô hiệu hóa bộ nhớ đệm phía máy chủ, đồng thời sử dụng TanStack Query để quản lý dữ liệu phía client, bao gồm các cập nhật lạc quan và vô hiệu hóa bộ nhớ đệm cục bộ.

Advertisement

Thiết lập ban đầu và khử nước

Để đảm bảo trải nghiệm người dùng liền mạch, đặc biệt là khi tải trang ban đầu, chúng ta khử nước bộ nhớ đệm TanStack Query trên máy chủ và khôi phục nó trên client. Điều này ngăn chặn việc hiển thị biểu tượng tải trên lần hiển thị đầu tiên đối với dữ liệu có thể đã được tìm nạp trong quá trình SSR/SSG.

Đầu tiên, thiết lập QueryClientProvider và HydrationBoundary.

'use client';

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

// Create a client
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Aggressively refetch on mount to ensure data freshness,
      // especially after server-side revalidation.
      staleTime: 5 * 1000, // 5 seconds
      refetchOnWindowFocus: true,
      refetchOnMount: true,
      refetchOnReconnect: true,
    },
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={dehydrate(queryClient)}>
        {children}
      </HydrationBoundary>
    </QueryClientProvider>
  );
}

Sau đó, trong bố cục gốc hoặc một trang cụ thể, tìm nạp trước dữ liệu và khử nước trạng thái.

import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { Providers } from './providers';
import { getPosts } from '@/lib/data'; // Example data fetching function

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const queryClient = new QueryClient();

  // Prefetch data on the server
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });

  return (
    <html lang="en">
      <body>
        <Providers>
          <HydrationBoundary state={dehydrate(queryClient)}>
            {children}
          </HydrationBoundary>
        </Providers>
      </body>
    </html>
  );
}

Lưu ý HydrationBoundary lồng nhau. Cái bên ngoài trong layout.tsx khôi phục dữ liệu được tìm nạp trước trong quá trình SSR cho lần tải trang ban đầu. Cái bên trong trong providers.tsx về mặt kỹ thuật là thừa cho lần tải ban đầu nhưng đảm bảo rằng mọi điều hướng phía client hoặc gắn kết thành phần tiếp theo trong ngữ cảnh Providers vẫn có thể tận dụng quá trình khôi phục nếu dehydrate(queryClient) được gọi lại trong một ngữ cảnh khác (ví dụ: một bố cục lồng nhau). Đối với một thiết lập điển hình, HydrationBoundary bên ngoài là đủ.

Cập nhật lạc quan với Server Actions

Các cập nhật lạc quan cung cấp phản hồi UI ngay lập tức, nâng cao hiệu suất cảm nhận. Khi người dùng thực hiện một hành động (ví dụ: thêm một bài đăng), UI sẽ cập nhật ngay lập tức, giả sử hành động sẽ thành công. Nếu hành động máy chủ thất bại, UI sẽ hoàn tác về trạng thái trước đó.

Hãy xem xét một kịch bản trong đó người dùng có thể thêm bài đăng mới.

'use server';

import { revalidatePath, revalidateTag } from 'next/cache';
import { Post } from '@/lib/types'; // Assume Post type is defined

let posts: Post[] = [
  { id: '1', title: 'Initial Post', content: 'This is the first post.' },
];
let nextId = 2;

export async function addPostAction(title: string, content: string): Promise<Post> {
  // Simulate network delay
  await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000 + 500));

  // Simulate an error 20% of the time
  if (Math.random() < 0.2) {
    throw new Error('Failed to add post: Network error or server issue.');
  }

  const newPost: Post = { id: String(nextId++), title, content };
  posts.unshift(newPost); // Add to the beginning for easier optimistic update
  console.log('Server: Added post', newPost);

  // Invalidate Next.js cache for the path where posts are displayed
  revalidatePath('/posts');
  revalidateTag('posts'); // Invalidate a specific tag if used

  return newPost;
}

export async function getPosts(): Promise<Post[]> {
  // Simulate network delay
  await new Promise((resolve) => setTimeout(resolve, 300));
  return posts;
}

Bây giờ, thành phần client:

'use client';

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { addPostAction, getPosts } from '@/app/actions';
import { Post } from '@/lib/types'; // Assume Post type is defined
import { useState } from 'react';

export default function PostsPage() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const { data: posts, isLoading, isError, error } = useQuery<Post[]>({
    queryKey: ['posts'],
    queryFn: getPosts,
  });

  const addPostMutation = useMutation({
    mutationFn: async ({ title, content }: { title: string; content: string }) => {
      return addPostAction(title, content);
    },
    onMutate: async (newPostData) => {
      // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
      await queryClient.cancelQueries({ queryKey: ['posts'] });

      // Snapshot the previous value
      const previousPosts = queryClient.getQueryData<Post[]>(['posts']);

      // Optimistically update to the new value
      queryClient.setQueryData<Post[]>(['posts'], (old) => {
        const tempId = `optimistic-${Date.now()}`; // Temporary ID for optimistic item
        const optimisticPost: Post = { ...newPostData, id: tempId };
        return old ? [optimisticPost, ...old] : [optimisticPost];
      });

      // Return a context object with the snapshotted value
      return { previousPosts };
    },
    onError: (err, newPostData, context) => {
      // If the mutation fails, use the context for an immediate rollback
      console.error('Optimistic update failed:', err);
      queryClient.setQueryData(['posts'], context?.previousPosts);
      // Optionally, show a toast notification
      alert(`Failed to add post: ${err.message}`);
    },
    onSettled: () => {
      // Always refetch after error or success:
      // This ensures our client-side cache is eventually consistent with the server.
      // It also handles cases where the server-side revalidation might not immediately
      // propagate to the client's data fetching mechanism (e.g., if the client
      // is still using stale data from its own cache).
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
    onSuccess: (data) => {
      // Optionally, if the server returns the full updated list or a specific ID,
      // you could update the optimistic item with the real ID.
      // For simplicity, `onSettled`'s `invalidateQueries` handles this.
      console.log('Post added successfully:', data);
      setTitle('');
      setContent('');
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!title || !content) return;
    addPostMutation.mutate({ title, content });
  };

  if (isLoading) return <div>Loading posts...</div>;
  if (isError) return <div>Error: {error?.message}</div>;

  return (
    <div className="max-w-2xl mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Posts</h1>

      <form onSubmit={handleSubmit} className="mb-8 p-4 border rounded-lg shadow-sm">
        <h2 className="text-xl font-semibold mb-3">Add New Post</h2>
        <div className="mb-3">
          <label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
          <input
            type="text"
            id="title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2"
            disabled={addPostMutation.isPending}
          />
        </div>
        <div className="mb-3">
          <label htmlFor="content" className="block text-sm font-medium text-gray-700">Content</label>
          <textarea
            id="content"
            value={content}
            onChange={(e) => setContent(e.target.value)}
            rows={3}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2"
            disabled={addPostMutation.isPending}
          ></textarea>
        </div>
        <button
          type="submit"
          className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
          disabled={addPostMutation.isPending}
        >
          {addPostMutation.isPending ? 'Adding...' : 'Add Post'}
        </button>
        {addPostMutation.isError && (
          <p className="text-red-500 text-sm mt-2">Error: {addPostMutation.error?.message}</p>
        )}
      </form>

      <div className="space-y-4">
        {posts?.map((post) => (
          <div key={post.id} className="p-4 border rounded-lg shadow-sm bg-white">
            <h3 className="text-lg font-semibold">{post.title}</h3>
            <p className="text-gray-600">{post.content}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Các khía cạnh chính của cập nhật lạc quan:

  1. onMutate:

    • queryClient.cancelQueries: Ngăn chặn mọi tìm nạp lại nền đang diễn ra ghi đè lên cập nhật lạc quan của chúng ta.
    • queryClient.getQueryData: Chụp nhanh dữ liệu hiện tại trước khi sửa đổi. Điều này rất quan trọng để hoàn tác.
    • queryClient.setQueryData: Cập nhật ngay lập tức bộ nhớ đệm phía client với mục mới, được thêm vào một cách lạc quan. Một ID tạm thời (optimistic-${Date.now()}) được sử dụng để phân biệt nó với các mục được máy chủ xác nhận.
    • Trả về previousPosts trong một đối tượng ngữ cảnh, được truyền đến onError.
  2. onError:

    • Nếu addPostAction thất bại, onError được gọi.
    • queryClient.setQueryData được sử dụng với context.previousPosts để khôi phục bộ nhớ đệm phía client về trạng thái trước khi cập nhật lạc quan.
  3. onSettled:

    • Callback này chạy bất kể thành công hay thất bại.
    • queryClient.invalidateQueries({ queryKey: ['posts'] }): Đánh dấu truy vấn ['posts'] là cũ. Điều này kích hoạt tìm nạp lại nền, đảm bảo bộ nhớ đệm phía client cuối cùng phản ánh trạng thái máy chủ thực. Điều này rất quan trọng để đồng bộ hóa với revalidatePath/revalidateTag trên máy chủ. Ngay cả khi máy chủ đã xác thực lại bộ nhớ đệm thành công, client cần tìm nạp lại để lấy dữ liệu mới nhất.

Cuộn vô hạn với useInfiniteQuery

Việc triển khai cuộn vô hạn đòi hỏi phải xử lý cẩn thận việc tổng hợp dữ liệu và ngăn chặn sự không khớp khi khôi phục. useInfiniteQuery từ TanStack Query là lý tưởng cho việc này.

// ... (previous actions)

export async function getPaginatedPosts(pageParam: number, limit: number = 5): Promise<{ posts: Post[]; nextPage: number | undefined }> {
  await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay

  const startIndex = (pageParam - 1) * limit;
  const endIndex = startIndex + limit;
  const paginatedPosts = posts.slice(startIndex, endIndex);

  const nextPage = endIndex < posts.length ? pageParam + 1 : undefined;

  return { posts: paginatedPosts, nextPage };
}
'use client';

import { useInfiniteQuery } from '@tanstack/react-query';
import { getPaginatedPosts } from '@/app/actions';
import { Post } from '@/lib/types';
import { useEffect, useRef } from 'react';

export default function InfinitePostsPage() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error,
  } = useInfiniteQuery({
    queryKey: ['infinitePosts'],
    queryFn: ({ pageParam }) => getPaginatedPosts(pageParam as number),
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
    staleTime: 1000 * 60 * 5, // 5 minutes
    gcTime: 1000 * 60 * 60, // 1 hour
  });

  const observerTarget = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!observerTarget.current) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 1 }
    );

    observer.observe(observerTarget.current);

    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current);
      }
    };
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);

  if (isLoading) return <div>Loading infinite posts...</div>;
  if (isError) return <div>Error: {error?.message}</div>;

  const allPosts = data?.pages.flatMap((page) => page.posts) || [];

  return (
    <div className="max-w-2xl mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Infinite Posts</h1>
      <div className="space-y-4">
        {allPosts.map((post) => (
          <div key={post.id} className="p-4 border rounded-lg shadow-sm bg-white">
            <h3 className="text-lg font-semibold">{post.title}</h3>
            <p className="text-gray-600">{post.content}</p>
          </div>
        ))}
      </div>
      <div ref={observerTarget} className="h-1 bg-transparent my-4" />
      {isFetchingNextPage && <div className="text-center py-2">Loading more...</div>}
      {!hasNextPage && allPosts.length > 0 && (
        <div className="text-center py-2 text-gray-500">No more posts to load.</div>
      )}
    </div>
  );
}

Các cân nhắc về sự không khớp khi khôi phục cho cuộn vô hạn

Khi sử dụng useInfiniteQuery với kết xuất phía máy chủ (SSR) hoặc tạo trang tĩnh (SSG), một cạm bẫy phổ biến là sự không khớp khi khôi phục. Nếu bạn chỉ tìm nạp trước trang đầu tiên trên máy chủ, nhưng useInfiniteQuery phía client cố gắng hiển thị nhiều trang hơn (ví dụ: do kết nối nhanh hoặc logic tiền kết xuất), HTML được kết xuất phía máy chủ sẽ khác với HTML được kết xuất phía client.

Chiến lược để tránh sự không khớp khi khôi phục:

  1. Chỉ tìm nạp trước trang đầu tiên trên máy chủ. Đây là cách tiếp cận phổ biến và an toàn nhất. Client sau đó sẽ tìm nạp các trang tiếp theo.

    import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
    import { getPaginatedPosts } from '@/app/actions';
    import { Providers } from '@/app/providers'; // Your existing providers
    
    export default async function InfinitePostsLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      const queryClient = new QueryClient();
    
      // Prefetch only the first page
      await queryClient.prefetchInfiniteQuery({
        queryKey: ['infinitePosts'],
        queryFn: ({ pageParam }) => getPaginatedPosts(pageParam as number),
        initialPageParam: 1,
      });
    
      return (
        <Providers>
          <HydrationBoundary state={dehydrate(queryClient)}>
            {children}
          </HydrationBoundary>
        </Providers>
      );
    }
    

    Điều này đảm bảo lần hiển thị ban đầu nhất quán. Các trang tiếp theo được tìm nạp phía client.

  2. Tránh tìm nạp trước nhiều trang trên máy chủ trừ khi thực sự cần thiết và bạn có thể đảm bảo kết xuất nhất quán. Nếu bạn tìm nạp trước nhiều trang, hãy đảm bảo logic kết xuất phía client cho useInfiniteQuery có thể xây dựng lại chính xác cùng một cấu trúc HTML dựa trên trạng thái đã khử nước. Điều này phức tạp và thường dẫn đến các vấn đề.

Advertisement

Đồng bộ hóa bộ nhớ đệm: revalidatePath so với revalidateTag

Next.js 15 cung cấp hai phương pháp chính để vô hiệu hóa bộ nhớ đệm dữ liệu của nó:

  • revalidatePath(path: string): Vô hiệu hóa bộ nhớ đệm cho một đường dẫn cụ thể. Khi đường dẫn được yêu cầu tiếp theo, nó sẽ được kết xuất lại. Hữu ích cho các trang hiển thị dữ liệu từ một nguồn duy nhất.
  • revalidateTag(tag: string): Vô hiệu hóa bộ nhớ đệm cho tất cả dữ liệu được tìm nạp bằng một thẻ cụ thể. Điều này chi tiết và mạnh mẽ hơn, cho phép vô hiệu hóa dữ liệu trên nhiều đường dẫn chia sẻ một nguồn dữ liệu chung (ví dụ: tất cả bài đăng, tất cả sản phẩm).

Khi nào nên sử dụng cái nào:

Tính năngrevalidatePathrevalidateTag
Mức độ chi tiếtCấp độ trangCấp độ dữ liệu (trên các trang)
Trường hợp sử dụngCập nhật một trang cụ thể (ví dụ: /blog/post-123)Cập nhật tất cả các trang hiển thị 'bài đăng' hoặc 'sản phẩm'
Thiết lậpTự động cho các lệnh gọi fetch trong một đường dẫnYêu cầu gắn thẻ các lệnh gọi fetch
Độ phức tạpĐơn giản hơn cho các trang riêng biệtThiết lập nhiều hơn, nhưng linh hoạt hơn cho dữ liệu dùng chung
Tác độngKết xuất lại đường dẫn được chỉ định trong yêu cầu tiếp theoKết xuất lại tất cả các đường dẫn sử dụng thẻ bị vô hiệu hóa

Ví dụ với revalidateTag:

Để sử dụng revalidateTag, bạn phải gắn thẻ các yêu cầu fetch của mình. Nếu bạn đang sử dụng một lớp tìm nạp dữ liệu tùy chỉnh (như ví dụ getPosts của chúng ta), bạn thường sẽ bao bọc fetch hoặc sử dụng một thư viện hỗ trợ gắn thẻ. Đối với Server Actions, revalidateTag hoạt động trực tiếp.

// ... (previous actions)

// Example of how you might tag a fetch request if you were fetching directly
// async function getPostsFromAPI(): Promise<Post[]> {
//   const res = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });
//   if (!res.ok) throw new Error('Failed to fetch posts');
//   return res.json();
// }

// Our current `getPosts` is a direct function call, so `revalidateTag`
// in `addPostAction` directly invalidates the tag, assuming Next.js
// tracks this for Server Actions. For `fetch` calls, explicit tagging is needed.
// For simplicity in this example, `revalidateTag('posts')` is called,
// implying that any data fetching mechanism that Next.js tracks under 'posts'
// will be invalidated. In a real app, ensure your data fetching is tagged.

Trong addPostAction của chúng ta, chúng ta sử dụng cả revalidatePath('/posts') và revalidateTag('posts'). Điều này cung cấp một chiến lược vô hiệu hóa mạnh mẽ:

  • revalidatePath('/posts') đảm bảo rằng trang /posts cụ thể được kết xuất lại trong yêu cầu tiếp theo.
  • revalidateTag('posts') sẽ vô hiệu hóa bất kỳ trang hoặc thành phần nào khác có thể đang tìm nạp dữ liệu được gắn thẻ là 'bài đăng'.

Ở phía client, queryClient.invalidateQueries({ queryKey: ['posts'] }) của TanStack Query đảm bảo rằng bộ nhớ đệm của client cho ['posts'] được đánh dấu là cũ, kích hoạt tìm nạp lại. Việc tìm nạp lại này sau đó sẽ truy cập máy chủ Next.js, máy chủ này, do revalidatePath/revalidateTag, sẽ phục vụ dữ liệu mới.

Những vấn đề và cách khắc phục trong sản xuất

  1. Dữ liệu cũ sau Server Action:

    • Triệu chứng: Người dùng thực hiện một hành động, cập nhật lạc quan hoạt động, nhưng sau khi hành động máy chủ hoàn tất, dữ liệu bị hoàn tác hoặc không cập nhật đúng cách.
    • Nguyên nhân: Thiếu hoặc sai revalidatePath/revalidateTag trên máy chủ, hoặc thiếu queryClient.invalidateQueries trên client.
    • Khắc phục: Đảm bảo Server Action của bạn gọi rõ ràng revalidatePath cho (các) trang bị ảnh hưởng và/hoặc revalidateTag cho các thẻ dữ liệu liên quan. Trên client, luôn gọi queryClient.invalidateQueries trong onSettled hoặc onSuccess của useMutation của bạn để buộc tìm nạp lại.
  2. Không khớp khi khôi phục (Warning: Prop className did not match. Server: "..." Client: "..."):

    • Triệu chứng: Lỗi trong console liên quan đến hydration hoặc did not match. Thường xảy ra với cuộn vô hạn hoặc nội dung động.
    • Nguyên nhân: HTML được kết xuất trên máy chủ khác với những gì React kết xuất trên client. Điều này có thể xảy ra nếu các hiệu ứng phía client (ví dụ: useEffect sửa đổi DOM, Date.now(), Math.random()) chạy trước khi khôi phục, hoặc nếu useInfiniteQuery tìm nạp các lượng dữ liệu khác nhau trên máy chủ so với client.
    • Khắc phục:
      • Đối với cuộn vô hạn, chỉ tìm nạp trước trang đầu tiên trên máy chủ. Để client tìm nạp các trang tiếp theo.
      • Đảm bảo bất kỳ thành phần chỉ dành cho client nào có thể gây ra sự không khớp được bao bọc trong <ClientOnly> hoặc được nhập động với ssr: false.
      • Tránh logic cụ thể phía client (như Date.now()) trong các thành phần được kết xuất trên máy chủ.
  3. Tìm nạp quá nhiều/quá ít dữ liệu khi tải ban đầu:

    • Triệu chứng: Trang tải với biểu tượng tải cho dữ liệu đáng lẽ phải có sẵn, hoặc tìm nạp quá nhiều dữ liệu.
    • Nguyên nhân: Thiết lập prefetchQuery hoặc HydrationBoundary không chính xác.
    • Khắc phục: Xác minh queryClient.prefetchQuery được gọi trước dehydrate(queryClient). Đảm bảo HydrationBoundary bao bọc các thành phần sử dụng dữ liệu được tìm nạp trước. Đối với các truy vấn vô hạn, chỉ tìm nạp trước trang đầu tiên.
  4. Các vấn đề hoàn tác cập nhật lạc quan:

    • Triệu chứng: Cập nhật lạc quan thất bại, nhưng UI không hoàn tác, hoặc hoàn tác không chính xác.
    • Nguyên nhân: Chụp nhanh onMutate không chính xác hoặc logic hoàn tác onError.
    • Khắc phục: Kiểm tra kỹ xem onMutate có chụp chính xác previousPosts và onError có sử dụng ảnh chụp nhanh này để setQueryData về trạng thái ban đầu hay không. Đảm bảo queryClient.cancelQueries được gọi để ngăn chặn các điều kiện tranh chấp.
  5. Lỗi Server Action không được truyền đi:

    • Triệu chứng: Server Action thất bại, nhưng useMutation's onError không được kích hoạt, hoặc thông báo lỗi chung chung.
    • Nguyên nhân: Server Action không ném rõ ràng một đối tượng Error, hoặc lỗi bị bắt và không được ném lại.
    • Khắc phục: Đảm bảo Server Actions của bạn throw new Error('...') cho các lỗi. Next.js sau đó sẽ truyền lỗi này một cách chính xác đến callback onError của useMutation phía client.

Câu hỏi thường gặp

  1. Khi nào tôi nên sử dụng revalidatePath so với revalidateTag? Sử dụng revalidatePath khi một thay đổi chủ yếu ảnh hưởng đến dữ liệu được hiển thị trên một trang cụ thể, duy nhất. Sử dụng revalidateTag khi một thay đổi ảnh hưởng đến dữ liệu có thể được hiển thị trên nhiều trang hoặc thành phần, cho phép vô hiệu hóa dữ liệu dùng chung một cách chi tiết và hiệu quả hơn. Để có độ bền tối đa, bạn có thể sử dụng cả hai nếu có thể, như được hiển thị trong ví dụ.

  2. Làm cách nào để xử lý xác thực và ủy quyền với Server Actions và TanStack Query? Server Actions chạy trên máy chủ, vì vậy bạn có thể truy cập trực tiếp các ngữ cảnh xác thực phía máy chủ (ví dụ: từ NextAuth.js, Clerk hoặc quản lý phiên tùy chỉnh). Thực hiện kiểm tra ủy quyền trong Server Actions của bạn. Nếu không được ủy quyền, hãy ném lỗi. useMutation của TanStack Query sẽ bắt lỗi này trong onError, cho phép bạn hiển thị phản hồi UI thích hợp. Đối với việc tìm nạp dữ liệu, useQuery cũng có thể gọi Server Actions, có thể thực hiện kiểm tra xác thực.

  3. Tôi có thể sử dụng TanStack Query cho tất cả việc tìm nạp dữ liệu, ngay cả đối với các lần tải trang ban đầu trên máy chủ không? Vâng, hoàn toàn có thể. Bằng cách sử dụng queryClient.prefetchQuery trong Next.js Server Components hoặc các tệp layout.tsx/page.tsx của bạn, và sau đó khử nước trạng thái bằng HydrationBoundary, bạn có thể tận dụng bộ nhớ đệm và quản lý dữ liệu của TanStack Query cho cả kết xuất phía máy chủ và phía client, cung cấp một lớp dữ liệu thống nhất.

  4. Vai trò của staleTime và gcTime trong TanStack Query với Next.js là gì? staleTime định nghĩa thời gian dữ liệu được coi là "mới". Khi còn mới, useQuery sẽ không tìm nạp lại khi kết xuất lại hoặc gắn kết thành phần. Khi cũ, nó sẽ tìm nạp lại trong nền. gcTime (thời gian thu gom rác) định nghĩa thời gian các truy vấn không hoạt động còn lại trong bộ nhớ đệm trước khi bị thu gom rác. Trong một ứng dụng Next.js, staleTime giúp giảm các yêu cầu mạng không cần thiết khi điều hướng phía client, trong khi gcTime quản lý việc sử dụng bộ nhớ. Điều chỉnh những điều này dựa trên sự biến động của dữ liệu và các ràng buộc bộ nhớ của ứng dụng của bạn.

  5. Làm cách nào để quản lý các trạng thái biểu mẫu phức tạp với các cập nhật lạc quan? Đối với các biểu mẫu phức tạp, hãy cân nhắc sử dụng một thư viện biểu mẫu như React Hook Form. Khi gửi, hãy sử dụng useMutation như đã mô tả. Thư viện biểu mẫu quản lý trạng thái đầu vào, và useMutation xử lý việc gửi, cập nhật lạc quan và hoàn tác lỗi. Xóa các trường biểu mẫu trong onSuccess của useMutation.

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement
Next.js Server Actions
tech

Next.js Server Actions

Nắm vững Next.js Server Actions để thay đổi dữ liệu toàn diện: khám phá cơ chế biên dịch, hành động biểu mẫu, bộ nhớ đệm revalidatePath và ranh giới bảo mật.

Read more