Chapter 05

客户端集成(React + TanStack Query)

从 Provider 配置到乐观更新,掌握 tRPC React 客户端的完整用法

5.1 安装与配置

pnpm add @trpc/react-query @tanstack/react-query
pnpm add @trpc/client @trpc/server
SHELL

创建 tRPC 客户端单例

// lib/trpc.ts — 客户端 tRPC 实例
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router';

// 注意:只导入类型,不导入运行时代码
export const trpc = createTRPCReact<AppRouter>();
TS

5.2 TRPCProvider:包裹应用

// components/providers.tsx
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { trpc } from '@/lib/trpc';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,     // 1 分钟内不重新请求
        retry: false,             // 失败不自动重试(tRPC 错误一般不需要)
      },
    },
  }));

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        // 开发环境打印请求日志
        loggerLink({
          enabled: (opts) =>
            process.env.NODE_ENV === 'development' ||
            (opts.direction === 'down' && opts.result instanceof Error),
        }),
        // HTTP 批量链接(默认,合并多个请求为一个 HTTP 请求)
        httpBatchLink({
          url: '/api/trpc',
          headers: () => {
            const token = getAuthToken();
            return token ? { Authorization: `Bearer ${token}` } : {};
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
TSX

5.3 useQuery:查询数据

'use client';
import { trpc } from '@/lib/trpc';

export function PostList() {
  // 基础查询
  const { data, isLoading, error, refetch } =
    trpc.post.list.useQuery({ page: 1, pageSize: 20 });
  //                       ↑ 类型自动推断,错误的参数 TypeScript 会报错

  if (isLoading) return <div>加载中...</div>;
  if (error) return <div>错误:{error.message}</div>;

  return (
    <ul>
      {data?.map(post => (
        <li key={post.id}>{post.title}</li
        {/* post.title 类型完全推断,IDE 有自动补全 */}
      ))}
    </ul>
  );
}
TSX

useQuery 高级选项

// 条件查询(enabled)
const { data: user } = trpc.user.getById.useQuery(
  { id: userId },
  {
    enabled: !!userId,        // userId 为空时不发请求
    staleTime: 5 * 60000,    // 5 分钟内不重新请求
    refetchOnWindowFocus: false,
    select: (data) => data.name, // 只订阅 name 字段变化
  }
);

// 分页查询
const [page, setPage] = useState(1);
const { data } = trpc.post.list.useQuery({ page, pageSize: 10 }, {
  placeholderData: keepPreviousData, // 翻页时保持上页数据,避免闪烁
});
TSX

5.4 useMutation:变更数据

'use client';
import { trpc } from '@/lib/trpc';
import { useQueryClient } from '@tanstack/react-query';
import { getQueryKey } from '@trpc/react-query';

export function CreatePostForm() {
  const queryClient = useQueryClient();

  const createPost = trpc.post.create.useMutation({
    onSuccess(newPost) {
      // 创建成功后,使文章列表缓存失效,触发重新请求
      const listKey = getQueryKey(trpc.post.list);
      queryClient.invalidateQueries({ queryKey: listKey });
    },
    onError(error) {
      // error 类型完全推断
      console.error(error.message);
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    createPost.mutate({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="标题" />
      <textarea name="content" placeholder="内容" />
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? '提交中...' : '发布文章'}
      </button>
      {createPost.error && <p>{createPost.error.message}</p>}
    </form>
  );
}
TSX

5.5 乐观更新(Optimistic Updates)

乐观更新在请求发出前就立即更新 UI,请求成功后确认,失败时回滚。这让操作感觉即时响应,提升用户体验。

export function TodoList() {
  const queryClient = useQueryClient();
  const listKey = getQueryKey(trpc.todo.list, undefined, 'query');

  const toggleTodo = trpc.todo.toggle.useMutation({
    async onMutate(variables) {
      // 取消进行中的请求,避免与乐观更新冲突
      await queryClient.cancelQueries({ queryKey: listKey });

      // 保存当前数据,用于回滚
      const previousTodos = queryClient.getQueryData(listKey);

      // 乐观地更新缓存
      queryClient.setQueryData(listKey, (old: Todo[]) =>
        old.map(todo =>
          todo.id === variables.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      );

      return { previousTodos }; // 传给 onError 用于回滚
    },
    onError(err, variables, context) {
      // 请求失败:回滚到操作前的状态
      queryClient.setQueryData(listKey, context?.previousTodos);
    },
    onSettled() {
      // 无论成功/失败,都重新请求一次确保数据最新
      queryClient.invalidateQueries({ queryKey: listKey });
    },
  });

  // ... 渲染 Todo 列表
}
TSX

5.6 完整 CRUD 页面实战

'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc';
import { getQueryKey } from '@trpc/react-query';
import { useQueryClient } from '@tanstack/react-query';

export default function PostsPage() {
  const queryClient = useQueryClient();
  const [newTitle, setNewTitle] = useState('');

  // 读取列表
  const { data: posts, isLoading } = trpc.post.list.useQuery({ page: 1 });
  const listKey = getQueryKey(trpc.post.list);

  // 创建
  const createMutation = trpc.post.create.useMutation({
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: listKey });
      setNewTitle('');
    },
  });

  // 删除
  const deleteMutation = trpc.post.delete.useMutation({
    onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey }),
  });

  if (isLoading) return <p>加载中...</p>;

  return (
    <div>
      {/* 创建表单 */}
      <form onSubmit={(e) => {
        e.preventDefault();
        createMutation.mutate({ title: newTitle, content: '内容...' });
      }}>
        <input
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          placeholder="新文章标题"
        />
        <button type="submit" disabled={createMutation.isPending}>
          发布
        </button>
      </form>

      {/* 列表 */}
      {posts?.map((post) => (
        <div key={post.id}>
          <span>{post.title}</span>
          <button
            onClick={() => deleteMutation.mutate({ id: post.id })}
            disabled={deleteMutation.isPending}
          >
            删除
          </button>
        </div>
      ))}
    </div>
  );
}
TSX
💡

本章小结createTRPCReact<AppRouter>() 创建类型安全的 React hooks。useQuery() 获取数据,useMutation() 执行写操作,两者均完整继承 TanStack Query 的缓存、后台刷新、错误状态能力。乐观更新通过 onMutate → onError(回滚)→ onSettled(刷新) 三步实现。getQueryKey(trpc.xxx) 获取标准化的缓存 key,用于 invalidateQueries