tRPC + TanStack Query統合ガイド — 型安全なデータフェッチングの極意
tRPCとTanStack Query(旧React Query)を組み合わせることで、型安全かつ高性能なデータフェッチングを実現できます。
この記事では、基本的な統合方法から高度なキャッシュ戦略、楽観的更新、無限スクロールまで、実践的なパターンを詳しく解説します。
tRPC + TanStack Queryの利点
なぜこの組み合わせが強力なのか
tRPCの強み:
- エンドツーエンド型安全
- スキーマ定義不要
- コード生成不要
TanStack Queryの強み:
- 自動キャッシュ管理
- バックグラウンド再取得
- 楽観的更新
- 無限スクロール対応
組み合わせると:
- 型安全 + 自動キャッシュ
- サーバー状態管理の完全自動化
- ユーザー体験の大幅向上
セットアップ
パッケージインストール
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query
npm install zod
tRPCクライアントの設定
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { QueryClient } from '@tanstack/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
export function createTRPCClient() {
return trpc.createClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: '/api/trpc',
headers() {
return {
authorization: getAuthToken(),
};
},
}),
],
});
}
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分
gcTime: 5 * 60 * 1000, // 5分(旧 cacheTime)
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
// 4xx エラーはリトライしない
if (error instanceof Error && 'statusCode' in error) {
const statusCode = (error as any).statusCode;
if (statusCode >= 400 && statusCode < 500) return false;
}
return failureCount < 3;
},
},
},
});
}
Providerの設定
// app/providers.tsx
'use client';
import { useState } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { trpc, createTRPCClient, createQueryClient } from '@/lib/trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => createQueryClient());
const [trpcClient] = useState(() => createTRPCClient());
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</trpc.Provider>
);
}
基本的なクエリとミューテーション
クエリの使用
// components/UserProfile.tsx
'use client';
import { trpc } from '@/lib/trpc';
export function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error, refetch } = trpc.user.getById.useQuery(
{ id: userId },
{
// クエリオプション
staleTime: 5 * 60 * 1000, // 5分間は再フェッチしない
enabled: !!userId, // userIdがある場合のみ実行
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}
ミューテーションの使用
// components/UpdateProfileForm.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { useState } from 'react';
export function UpdateProfileForm({ userId }: { userId: string }) {
const [name, setName] = useState('');
const utils = trpc.useUtils();
const updateProfile = trpc.user.update.useMutation({
onSuccess: (updatedUser) => {
// キャッシュを直接更新(楽観的更新の完了)
utils.user.getById.setData({ id: userId }, updatedUser);
// 関連するクエリを無効化
utils.user.list.invalidate();
},
onError: (error) => {
alert(`Error: ${error.message}`);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateProfile.mutate({ id: userId, name });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New name"
/>
<button type="submit" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Updating...' : 'Update'}
</button>
</form>
);
}
高度なキャッシュ戦略
キャッシュの手動更新
// hooks/usePostMutations.ts
import { trpc } from '@/lib/trpc';
export function usePostMutations() {
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onMutate: async (newPost) => {
// 進行中のクエリをキャンセル
await utils.post.list.cancel();
// 以前のデータを取得(ロールバック用)
const previousPosts = utils.post.list.getData();
// キャッシュを楽観的に更新
utils.post.list.setData(undefined, (old) => {
if (!old) return [newPost];
return [{ ...newPost, id: 'temp-id', createdAt: new Date() }, ...old];
});
return { previousPosts };
},
onError: (err, newPost, context) => {
// エラー時にロールバック
if (context?.previousPosts) {
utils.post.list.setData(undefined, context.previousPosts);
}
},
onSettled: () => {
// 成功・失敗に関わらず、最新データを取得
utils.post.list.invalidate();
},
});
const deletePost = trpc.post.delete.useMutation({
onMutate: async (deletedId) => {
await utils.post.list.cancel();
const previousPosts = utils.post.list.getData();
utils.post.list.setData(undefined, (old) => {
return old?.filter((post) => post.id !== deletedId.id);
});
return { previousPosts };
},
onError: (err, deletedId, context) => {
if (context?.previousPosts) {
utils.post.list.setData(undefined, context.previousPosts);
}
},
onSettled: () => {
utils.post.list.invalidate();
},
});
return { createPost, deletePost };
}
部分的なキャッシュ更新
// components/LikeButton.tsx
'use client';
import { trpc } from '@/lib/trpc';
export function LikeButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const likePost = trpc.post.like.useMutation({
onMutate: async ({ postId }) => {
// 単一の投稿キャッシュを更新
utils.post.getById.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
likes: old.likes + 1,
isLiked: true,
};
});
// リスト内の投稿も更新
utils.post.list.setData(undefined, (old) => {
if (!old) return old;
return old.map((post) =>
post.id === postId
? { ...post, likes: post.likes + 1, isLiked: true }
: post
);
});
},
});
const handleLike = () => {
likePost.mutate({ postId });
};
return (
<button onClick={handleLike} disabled={likePost.isPending}>
Like
</button>
);
}
無限スクロールの実装
サーバー側の設定
// server/routers/post.ts
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
export const postRouter = router({
infinitePosts: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ input }) => {
const { limit, cursor } = input;
const posts = await db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined = undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem!.id;
}
return {
posts,
nextCursor,
};
}),
});
クライアント側の実装
// components/InfinitePostList.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
export function InfinitePostList() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
} = trpc.post.infinitePosts.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
))}
{/* 監視用の要素 */}
<div ref={ref} className="h-10 flex items-center justify-center">
{isFetchingNextPage && <div>Loading more...</div>}
</div>
{!hasNextPage && <div>No more posts</div>}
</div>
);
}
プリフェッチとSSR
Server Componentsでのプリフェッチ
// app/posts/[id]/page.tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '@/server/routers/_app';
import { PostDetail } from './PostDetail';
export default async function PostPage({ params }: { params: { id: string } }) {
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {},
});
// サーバー側でデータをプリフェッチ
await helpers.post.getById.prefetch({ id: params.id });
return (
<div>
<PostDetail postId={params.id} />
</div>
);
}
クライアントでのプリフェッチ
// components/PostLink.tsx
'use client';
import Link from 'next/link';
import { trpc } from '@/lib/trpc';
export function PostLink({ postId, title }: { postId: string; title: string }) {
const utils = trpc.useUtils();
const handleMouseEnter = () => {
// ホバー時にデータをプリフェッチ
utils.post.getById.prefetch({ id: postId });
};
return (
<Link
href={`/posts/${postId}`}
onMouseEnter={handleMouseEnter}
className="text-blue-600 hover:underline"
>
{title}
</Link>
);
}
Suspenseとの統合
Suspense対応のクエリ
// components/UserProfileSuspense.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { Suspense } from 'react';
function UserProfileContent({ userId }: { userId: string }) {
const [user] = trpc.user.getById.useSuspenseQuery({ id: userId });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
export function UserProfile({ userId }: { userId: string }) {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfileContent userId={userId} />
</Suspense>
);
}
複数のSuspenseクエリ
// components/DashboardSuspense.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { Suspense } from 'react';
function UserStats({ userId }: { userId: string }) {
const [stats] = trpc.user.stats.useSuspenseQuery({ userId });
return <div>Posts: {stats.postCount}</div>;
}
function UserPosts({ userId }: { userId: string }) {
const [posts] = trpc.post.byUser.useSuspenseQuery({ userId });
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export function Dashboard({ userId }: { userId: string }) {
return (
<div>
<Suspense fallback={<div>Loading stats...</div>}>
<UserStats userId={userId} />
</Suspense>
<Suspense fallback={<div>Loading posts...</div>}>
<UserPosts userId={userId} />
</Suspense>
</div>
);
}
エラーハンドリング
グローバルエラーハンドリング
// lib/trpc-client.ts
import { TRPCClientError } from '@trpc/client';
import { toast } from 'sonner';
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
if (error instanceof TRPCClientError) {
switch (error.data?.code) {
case 'UNAUTHORIZED':
toast.error('Please login to continue');
// リダイレクト処理
break;
case 'FORBIDDEN':
toast.error('You do not have permission');
break;
case 'NOT_FOUND':
toast.error('Resource not found');
break;
default:
toast.error('An error occurred');
}
} else {
toast.error('Network error');
}
},
},
},
});
}
コンポーネント単位のエラーハンドリング
// components/UserList.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { TRPCClientError } from '@trpc/client';
export function UserList() {
const { data, error, refetch } = trpc.user.list.useQuery();
if (error) {
if (error instanceof TRPCClientError) {
return (
<div className="error-container">
<p>Error: {error.message}</p>
<p>Code: {error.data?.code}</p>
<button onClick={() => refetch()}>Retry</button>
</div>
);
}
return <div>Unknown error occurred</div>;
}
if (!data) return <div>Loading...</div>;
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
パフォーマンス最適化
選択的なデータ取得
// components/UserName.tsx
'use client';
import { trpc } from '@/lib/trpc';
export function UserName({ userId }: { userId: string }) {
const { data } = trpc.user.getById.useQuery(
{ id: userId },
{
// 必要なフィールドのみ選択
select: (data) => data.name,
}
);
return <span>{data}</span>;
}
バッチリクエストの活用
// lib/trpc.ts
import { httpBatchLink } from '@trpc/client';
export function createTRPCClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
maxURLLength: 2083,
// 10ms以内のリクエストをバッチ化
maxBatchSize: 10,
}),
],
});
}
まとめ
tRPCとTanStack Queryの組み合わせは、モダンなWebアプリケーション開発における強力な武器です。
主なメリット:
- エンドツーエンド型安全
- 自動キャッシュ管理
- 楽観的更新による高速なUI
- 無限スクロールの簡単実装
- Suspense統合
ベストプラクティス:
- 適切なstaleTimeとcacheTimeを設定
- 楽観的更新でUX向上
- エラーハンドリングを忘れずに
- プリフェッチで体感速度向上
- バッチリクエストでネットワーク効率化
この組み合わせをマスターすれば、型安全かつ高性能なWebアプリケーションを効率的に構築できます。