Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

React Query (TanStack Query)

Giới thiệu

TanStack Query (trước đây là React Query) là thư viện server state management mạnh nhất hiện nay. Nó xử lý fetching, caching, synchronizing và updating server state.

Client State vs Server State:
- Client State: theme, language, UI state (dùng useState/Redux)
- Server State: API data, cần fetch, cache, sync (dùng React Query)

Cài đặt

npm install @tanstack/react-query @tanstack/react-query-devtools

Setup

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,   // Data cũ sau 5 phút
      gcTime: 10 * 60 * 1000,     // Xóa cache sau 10 phút không dùng
      retry: 3,                    // Retry 3 lần khi fail
      refetchOnWindowFocus: true,  // Refetch khi focus lại tab
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

useQuery - Fetching Data

import { useQuery } from '@tanstack/react-query';

// Query function
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
}

// Sử dụng
function UserProfile({ userId }: { userId: number }) {
  const {
    data: user,
    isLoading,
    isError,
    error,
    isFetching,      // true khi đang fetch (kể cả background)
    isStale,         // true khi data đã cũ
    refetch,         // Function để refetch thủ công
  } = useQuery({
    queryKey: ['user', userId],   // Unique key - dùng để cache & invalidate
    queryFn: () => fetchUser(userId),
    enabled: userId > 0,          // Chỉ fetch khi userId hợp lệ
    staleTime: 30_000,            // Override global staleTime
    select: (data) => ({          // Transform data
      ...data,
      fullName: `${data.firstName} ${data.lastName}`,
    }),
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <Alert message={(error as Error).message} />;

  return (
    <div>
      {isFetching && <span>Updating...</span>}
      <h1>{user?.fullName}</h1>
      <button onClick={() => refetch()}>Refresh</button>
    </div>
  );
}

Query Keys - Best Practices

// Query key xác định cache entry
// Khi key thay đổi → query mới được tạo

// Simple
useQuery({ queryKey: ['todos'], queryFn: getTodos });

// Với ID
useQuery({ queryKey: ['todo', todoId], queryFn: () => getTodo(todoId) });

// Với filters
useQuery({
  queryKey: ['todos', { status: 'active', page: 1 }],
  queryFn: () => getTodos({ status: 'active', page: 1 }),
});

// Query key factory pattern (best practice)
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: number) => [...userKeys.details(), id] as const,
};

useQuery({ queryKey: userKeys.detail(userId), queryFn: ... });

useMutation - Thay đổi Data

import { useMutation, useQueryClient } from '@tanstack/react-query';

async function createTodo(todo: CreateTodoDto): Promise<Todo> {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(todo),
  });
  if (!response.ok) throw new Error('Failed to create todo');
  return response.json();
}

function CreateTodoForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createTodo,

    // Optimistic update
    onMutate: async (newTodo) => {
      // Hủy queries đang chạy để tránh overwrite
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Lưu snapshot state hiện tại
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Optimistically update cache
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
        ...old,
        { id: Date.now(), ...newTodo, status: 'pending' },
      ]);

      return { previousTodos }; // Context để rollback
    },

    onError: (err, newTodo, context) => {
      // Rollback khi có lỗi
      queryClient.setQueryData(['todos'], context?.previousTodos);
      toast.error('Failed to create todo');
    },

    onSuccess: (data) => {
      // Invalidate để refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      toast.success('Todo created!');
    },

    onSettled: () => {
      // Chạy dù thành công hay thất bại
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const form = e.target as HTMLFormElement;
    mutation.mutate({
      title: form.title.value,
      description: form.description.value,
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" required />
      <textarea name="description" />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Todo'}
      </button>
      {mutation.isError && (
        <p className="error">{(mutation.error as Error).message}</p>
      )}
    </form>
  );
}

Pagination

import { useQuery, keepPreviousData } from '@tanstack/react-query';

async function fetchTodos(page: number, pageSize: number) {
  const response = await fetch(
    `/api/todos?page=${page}&pageSize=${pageSize}`
  );
  return response.json() as Promise<{ items: Todo[]; total: number }>;
}

function TodoList() {
  const [page, setPage] = useState(1);
  const pageSize = 10;

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ['todos', { page, pageSize }],
    queryFn: () => fetchTodos(page, pageSize),
    placeholderData: keepPreviousData, // Giữ data cũ khi fetch trang mới
  });

  const totalPages = Math.ceil((data?.total ?? 0) / pageSize);

  return (
    <div>
      {isLoading ? (
        <Spinner />
      ) : (
        <ul>
          {data?.items.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      )}

      <div className="pagination">
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          Previous
        </button>
        <span>
          Page {page} of {totalPages}
        </span>
        <button
          onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
          disabled={page === totalPages || isPlaceholderData}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Infinite Scroll

import { useInfiniteQuery } from '@tanstack/react-query';
import { useIntersection } from '@mantine/hooks'; // hoặc custom hook

async function fetchPosts({ pageParam = 1 }) {
  const response = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
  return response.json() as Promise<{
    items: Post[];
    nextCursor: number | null;
  }>;
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  const bottomRef = useRef<HTMLDivElement>(null);

  // Intersection Observer để auto-load
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    if (bottomRef.current) observer.observe(bottomRef.current);
    return () => observer.disconnect();
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);

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

  return (
    <div>
      {allPosts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      {isFetchingNextPage && <Spinner />}
      <div ref={bottomRef} className="h-4" />
    </div>
  );
}

prefetchQuery & Cache Management

const queryClient = useQueryClient();

// Prefetch trước khi user navigate
async function prefetchUser(userId: number) {
  await queryClient.prefetchQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 10_000,
  });
}

// Invalidate - buộc refetch
queryClient.invalidateQueries({ queryKey: ['users'] });

// Set data trực tiếp
queryClient.setQueryData(['user', 1], updatedUser);

// Xóa cache
queryClient.removeQueries({ queryKey: ['user', userId] });

// Hover prefetch pattern
function UserLink({ userId, name }: { userId: number; name: string }) {
  const queryClient = useQueryClient();

  return (
    <a
      href={`/users/${userId}`}
      onMouseEnter={() => prefetchUser(userId)} // Prefetch khi hover
    >
      {name}
    </a>
  );
}