T-CREATOR

Jotai × tRPC 初期配線:型安全 RPC とローカル状態の統合

Jotai × tRPC 初期配線:型安全 RPC とローカル状態の統合

TypeScript で開発していると、「型の恩恵を最大限に受けたい」と思いますよね。特に、サーバーとクライアント間のデータのやり取りで型が壊れてしまうと、バグの温床になってしまいます。

今回は、型安全な RPC フレームワークである tRPC と、軽量で柔軟な状態管理ライブラリ Jotai を組み合わせた初期配線の方法をご紹介します。この組み合わせにより、サーバーからクライアントまで一貫した型安全性を保ちながら、ローカル状態とリモート状態を統合的に管理できるのです。

実際のプロジェクトで導入する際の具体的な手順を、コード例とともに段階的に解説していきますね。

背景

Jotai とは何か

Jotai は、React アプリケーションのための最小限の状態管理ライブラリです。「atom(原子)」という小さな状態の単位を組み合わせて、複雑な状態を構築していく思想が特徴ですね。

Redux や Zustand などの既存のライブラリと比較すると、以下のような特徴があります。

#特徴説明
1最小限の APIシンプルで学習コストが低い
2アトミックな設計必要な状態だけを購読できる
3TypeScript フレンドリー型推論が優れている
4React Suspense 対応非同期処理と相性が良い

tRPC とは何か

tRPC は、End-to-End の型安全性を実現する RPC フレームワークです。GraphQL のようなスキーマ定義言語を使わず、TypeScript の型システムだけで型安全な API を構築できます。

以下の図は、tRPC を使った場合のデータフローを示しています。

mermaidflowchart LR
  client["クライアント<br/>(React)"] -->|型安全な呼び出し| trpc["tRPC Client"]
  trpc -->|HTTP Request| server["tRPC Server"]
  server -->|型情報を共有| router["Router<br/>(型定義)"]
  router -->|Response| server
  server -->|HTTP Response| trpc
  trpc -->|型安全なデータ| client

図で理解できる要点:

  • クライアントとサーバー間で型情報が自動的に共有される
  • 手動でスキーマを書く必要がない
  • エンドツーエンドで型安全性が保証される

なぜ両者を組み合わせるのか

Jotai は優れたローカル状態管理を提供しますが、サーバーとの通信部分は別途実装する必要があります。一方、tRPC は型安全なサーバー通信を実現しますが、クライアントサイドの状態管理機能は持っていません。

この二つを組み合わせることで、次のようなメリットが得られるのです。

#メリット詳細
1一貫した型安全性サーバーからクライアントまで途切れない型チェック
2柔軟な状態管理リモート状態とローカル状態を統一的に扱える
3最小限の再レンダリングJotai のアトミック設計で効率的な更新
4開発体験の向上自動補完とエラー検出が強力

課題

型安全性の断絶

従来の REST API や GraphQL を使った開発では、クライアントとサーバー間で型情報が断絶しがちです。OpenAPI や GraphQL Code Generator などのツールで型を生成できますが、手動での同期作業が必要になってしまいます。

以下の図は、従来の開発における型の断絶ポイントを示しています。

mermaidflowchart TB
  subgraph server_side["サーバーサイド"]
    server_type["TypeScript 型定義"]
  end

  subgraph boundary["境界(型が断絶)"]
    api["REST API / GraphQL"]
  end

  subgraph client_side["クライアントサイド"]
    codegen["コード生成ツール"]
    client_type["生成された型"]
    manual["手動同期"]
  end

  server_type -->|エクスポート| api
  api -->|スキーマ| codegen
  codegen -->|生成| client_type
  server_type -.->|手動で同期| manual
  manual -.->|更新| client_type

図で理解できる要点:

  • API の境界で型情報が失われる
  • コード生成ツールによる間接的な型共有が必要
  • サーバー側の型変更が自動的にクライアントに反映されない

状態管理の複雑化

サーバーから取得したデータをクライアントで状態として管理する場合、以下のような課題に直面します。

#課題影響
1キャッシュ管理同じデータを何度も取得してしまう
2楽観的更新UI の応答性を保つための実装が複雑
3エラーハンドリングローカルとリモートで異なる処理が必要
4ローディング状態複数の非同期処理の状態管理が煩雑

セットアップの難しさ

Jotai と tRPC を組み合わせようとすると、初期設定でつまずくポイントがいくつかあります。特に、以下のような疑問が生じやすいですね。

  • tRPC のクライアントをどこで初期化するか
  • Jotai の atom 内で tRPC をどう呼び出すか
  • 型情報をどうやって両者間で共有するか
  • エラーやローディング状態をどう統合管理するか

解決策

アーキテクチャの全体像

Jotai と tRPC を統合したアーキテクチャでは、tRPC を通じてサーバーから取得したデータを Jotai の atom で管理します。これにより、型安全性を保ちながら柔軟な状態管理が実現できるのです。

以下の図は、提案するアーキテクチャの全体像を示しています。

mermaidflowchart TB
  subgraph react["React コンポーネント"]
    component["Component"]
  end

  subgraph jotai_layer["Jotai レイヤー"]
    atom_query["queryAtom<br/>(tRPC 呼び出し)"]
    atom_local["localAtom<br/>(ローカル状態)"]
  end

  subgraph trpc_layer["tRPC レイヤー"]
    trpc_client["tRPC Client"]
    trpc_router["Router 型定義"]
  end

  subgraph server["サーバー"]
    trpc_server["tRPC Server"]
    db[("Database")]
  end

  component -->|useAtom| atom_query
  component -->|useAtom| atom_local
  atom_query -->|trpc.user.get| trpc_client
  trpc_client -->|HTTP| trpc_server
  trpc_server -->|クエリ| db
  trpc_router -.->|型共有| atom_query
  trpc_router -.->|型共有| trpc_client

図で理解できる要点:

  • React コンポーネントは atom を通じてデータにアクセス
  • atom 内で tRPC クライアントを呼び出す
  • 型情報は Router から自動的に共有される

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。Next.js プロジェクトを想定していますが、他の React フレームワークでも同様の手順で導入できますよ。

パッケージのインストール

bash# tRPC 関連
yarn add @trpc/server @trpc/client @trpc/react-query @trpc/next

# React Query(tRPC が内部で使用)
yarn add @tanstack/react-query

# Jotai
yarn add jotai

# Zod(バリデーション用)
yarn add zod

TypeScript の設定確認

tsconfig.json で厳格な型チェックを有効にしておくと、型安全性の恩恵を最大限に受けられます。

json{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": false
  }
}

tRPC サーバーの構築

Router の型定義

tRPC のサーバーサイドでは、まず Router を定義します。この Router の型が、クライアント側で自動的に利用されるのです。

typescript// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// tRPC インスタンスの初期化
const t = initTRPC.create();

// Router とプロシージャの作成
export const router = t.router;
export const publicProcedure = t.procedure;

プロシージャの実装

次に、実際の API エンドポイント(プロシージャ)を定義します。ここでは、ユーザー情報を取得する例を示しますね。

typescript// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

// ユーザー型の定義(Zod スキーマ)
const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.date(),
});

export const userRouter = router({
  // ユーザー取得
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // データベースからユーザーを取得(例)
      const user = await db.user.findUnique({
        where: { id: input.id },
      });

      return user;
    }),
});

ルートルーターの作成

個別のルーターを統合して、アプリケーション全体のルートルーターを作成します。

typescript// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

// すべてのルーターを統合
export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

// Router の型をエクスポート(重要!)
export type AppRouter = typeof appRouter;

この AppRouter 型が、クライアント側で型安全な呼び出しを実現するための鍵になります。

tRPC クライアントの設定

クライアントの初期化

クライアント側では、tRPC クライアントを初期化します。Next.js の場合、utils​/​trpc.ts に配置するのが一般的ですね。

typescript// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

// tRPC React フックの作成
export const trpc = createTRPCReact<AppRouter>();

型引数に AppRouter を渡すことで、サーバー側の型情報がクライアント側でも利用できるようになります。

Provider の設定

React アプリケーションのルートで、tRPC と React Query の Provider を設定します。

typescript// pages/_app.tsx
import { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';

function MyApp({ Component, pageProps }) {
  // QueryClient の初期化
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1分間キャッシュ
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

tRPC Provider の追加

tRPC 専用の Provider も追加します。

typescript// pages/_app.tsx(続き)
function MyApp({ Component, pageProps }) {
  const [queryClient] = useState(() => new QueryClient());

  // tRPC クライアントの設定
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc', // API エンドポイント
        }),
      ],
    })
  );

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

Jotai との統合

ここからが本題です。tRPC と Jotai を統合して、型安全な状態管理を実現していきましょう。

tRPC を呼び出す atom の作成

Jotai の atomWithQuery を使うと、React Query ライクな非同期処理を atom で扱えます。しかし、tRPC との統合ではもう少し工夫が必要なのです。

typescript// atoms/user.ts
import { atom } from 'jotai';
import { trpc } from '@/utils/trpc';

// tRPC クライアントを取得する atom(singleton)
const trpcClientAtom = atom(() => {
  return trpc.useContext();
});

ユーザー情報を管理する atom

ユーザー ID を受け取ってユーザー情報を取得する atom を作成します。

typescript// atoms/user.ts(続き)
import { atomFamily } from 'jotai/utils';

// ユーザー ID ごとに atom を作成
export const userAtomFamily = atomFamily((userId: string) =>
  atom(async (get) => {
    // tRPC 経由でユーザー情報を取得
    const client = get(trpcClientAtom);
    const user = await client.user.getById.fetch({
      id: userId,
    });

    return user;
  })
);

atomFamily を使うことで、ユーザー ID ごとに個別の atom インスタンスが作成され、効率的なキャッシュが実現できますよ。

ローカル状態との統合

リモートから取得したデータと、ローカルで管理する状態を組み合わせる例も見てみましょう。

typescript// atoms/userEdit.ts
import { atom } from 'jotai';
import { userAtomFamily } from './user';

// 編集中のユーザー情報を管理する atom
export const editingUserAtom = atom(
  // 読み取り:リモートのユーザー情報を取得
  (get) => {
    const userId = get(selectedUserIdAtom);
    if (!userId) return null;

    return get(userAtomFamily(userId));
  },

  // 書き込み:ローカルで編集内容を保持
  (get, set, update: Partial<User>) => {
    const current = get(editingUserAtom);
    if (!current) return;

    // 編集内容をマージ
    set(localEditAtom, { ...current, ...update });
  }
);

このパターンにより、サーバーのデータを元に、ローカルでの編集状態を管理できるようになります。

具体例

ユーザー一覧の表示

実際のコンポーネントで、Jotai と tRPC を組み合わせて使う例をご紹介します。

型定義の準備

まず、共通で使う型を定義しておきます。

typescript// types/user.ts
export type User = {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
};

export type UserListFilter = {
  search: string;
  sortBy: 'name' | 'createdAt';
  sortOrder: 'asc' | 'desc';
};

フィルター状態の atom

ユーザー一覧のフィルター条件を管理する atom を作成します。

typescript// atoms/userList.ts
import { atom } from 'jotai';
import type { UserListFilter } from '@/types/user';

// フィルター条件の初期値
const initialFilter: UserListFilter = {
  search: '',
  sortBy: 'name',
  sortOrder: 'asc',
};

// フィルター条件を管理する atom
export const userFilterAtom =
  atom<UserListFilter>(initialFilter);

ユーザー一覧取得の atom

フィルター条件に基づいてユーザー一覧を取得する atom を作成します。

typescript// atoms/userList.ts(続き)
import { trpc } from '@/utils/trpc';

// ユーザー一覧を取得する atom
export const userListAtom = atom(async (get) => {
  const filter = get(userFilterAtom);

  // tRPC 経由でユーザー一覧を取得
  const users = await trpc.user.list.query({
    search: filter.search,
    sortBy: filter.sortBy,
    sortOrder: filter.sortOrder,
  });

  return users;
});

型情報は tRPC の Router から自動的に推論されるため、手動で型を指定する必要はありません。

コンポーネントでの利用

作成した atom をコンポーネントで使ってみましょう。

typescript// components/UserList.tsx
import { useAtom, useAtomValue } from 'jotai';
import {
  userListAtom,
  userFilterAtom,
} from '@/atoms/userList';
import { Suspense } from 'react';

export function UserList() {
  return (
    <div>
      <UserListFilter />
      <Suspense fallback={<div>読み込み中...</div>}>
        <UserListContent />
      </Suspense>
    </div>
  );
}

フィルターコンポーネント

フィルター条件を変更する UI を作成します。

typescript// components/UserListFilter.tsx
function UserListFilter() {
  const [filter, setFilter] = useAtom(userFilterAtom);

  return (
    <div>
      <input
        type='text'
        placeholder='ユーザー名で検索'
        value={filter.search}
        onChange={(e) =>
          setFilter({
            ...filter,
            search: e.target.value,
          })
        }
      />

      <select
        value={filter.sortBy}
        onChange={(e) =>
          setFilter({
            ...filter,
            sortBy: e.target.value as 'name' | 'createdAt',
          })
        }
      >
        <option value='name'>名前順</option>
        <option value='createdAt'>登録日順</option>
      </select>
    </div>
  );
}

一覧表示コンポーネント

実際にユーザー一覧を表示するコンポーネントです。

typescript// components/UserListContent.tsx
function UserListContent() {
  // ユーザー一覧を取得
  const users = useAtomValue(userListAtom);

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <h3>{user.name}</h3>
          <p>{user.email}</p>
          <time>{user.createdAt.toLocaleDateString()}</time>
        </li>
      ))}
    </ul>
  );
}

useAtomValue で atom の値を読み取るだけで、tRPC を通じてサーバーからデータが取得され、型安全に利用できるのです。

ミューテーション(更新処理)の実装

次に、データを更新する処理を実装してみましょう。

更新用プロシージャの定義

サーバー側に、ユーザー情報を更新するプロシージャを追加します。

typescript// server/routers/user.ts(追加)
export const userRouter = router({
  // 既存の getById...

  // ユーザー更新
  update: publicProcedure
    .input(
      z.object({
        id: z.string(),
        name: z.string().optional(),
        email: z.string().email().optional(),
      })
    )
    .mutation(async ({ input }) => {
      // データベースを更新
      const updated = await db.user.update({
        where: { id: input.id },
        data: {
          name: input.name,
          email: input.email,
        },
      });

      return updated;
    }),
});

更新処理の atom

クライアント側で、更新処理を実行する atom を作成します。

typescript// atoms/userMutation.ts
import { atom } from 'jotai';
import { trpc } from '@/utils/trpc';
import type { User } from '@/types/user';

// ユーザー更新処理の atom
export const updateUserAtom = atom(
  null,
  async (
    get,
    set,
    update: Partial<User> & { id: string }
  ) => {
    try {
      // tRPC mutation を実行
      const updated = await trpc.user.update.mutate({
        id: update.id,
        name: update.name,
        email: update.email,
      });

      // キャッシュを更新
      set(userAtomFamily(update.id), updated);

      return { success: true, data: updated };
    } catch (error) {
      return { success: false, error };
    }
  }
);

この atom は書き込み専用で、実行するとサーバーへの更新リクエストが送信されます。

更新フォームコンポーネント

実際に更新フォームを作成してみましょう。

typescript// components/UserEditForm.tsx
import { useAtom, useSetAtom } from 'jotai';
import { useState } from 'react';
import { updateUserAtom } from '@/atoms/userMutation';

export function UserEditForm({
  userId,
}: {
  userId: string;
}) {
  const user = useAtomValue(userAtomFamily(userId));
  const updateUser = useSetAtom(updateUserAtom);

  const [name, setName] = useState(user.name);
  const [email, setEmail] = useState(user.email);
  const [loading, setLoading] = useState(false);

  return (
    <form onSubmit={handleSubmit}>
      {/* フォーム内容は次のブロックで */}
    </form>
  );
}

送信処理の実装

フォームの送信処理を実装します。

typescript// components/UserEditForm.tsx(続き)
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  setLoading(true);

  // atom を通じて更新処理を実行
  const result = await updateUser({
    id: userId,
    name,
    email,
  });

  setLoading(false);

  if (result.success) {
    alert('更新が完了しました');
  } else {
    alert('エラーが発生しました');
  }
};

フォーム UI

最後に、フォームの UI 部分を完成させます。

typescript// components/UserEditForm.tsx(フォーム部分)
return (
  <form onSubmit={handleSubmit}>
    <div>
      <label htmlFor='name'>名前</label>
      <input
        id='name'
        type='text'
        value={name}
        onChange={(e) => setName(e.target.value)}
        disabled={loading}
      />
    </div>

    <div>
      <label htmlFor='email'>メールアドレス</label>
      <input
        id='email'
        type='email'
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        disabled={loading}
      />
    </div>

    <button type='submit' disabled={loading}>
      {loading ? '更新中...' : '更新する'}
    </button>
  </form>
);

エラーハンドリングとローディング状態

実践的なアプリケーションでは、エラーとローディング状態の管理が重要です。

エラー状態を含む atom

エラーとローディング状態を含めた atom を作成します。

typescript// atoms/userWithState.ts
import { atom } from 'jotai';
import { trpc } from '@/utils/trpc';

type AsyncState<T> = {
  data: T | null;
  loading: boolean;
  error: Error | null;
};

// 状態付きユーザー取得 atom
export const userWithStateAtom = atomFamily(
  (userId: string) =>
    atom<AsyncState<User>>(async (get) => {
      try {
        const user = await trpc.user.getById.query({
          id: userId,
        });
        return {
          data: user,
          loading: false,
          error: null,
        };
      } catch (error) {
        return {
          data: null,
          loading: false,
          error: error as Error,
        };
      }
    })
);

エラー表示コンポーネント

エラー状態を考慮したコンポーネントを作成します。

typescript// components/UserProfile.tsx
export function UserProfile({
  userId,
}: {
  userId: string;
}) {
  const state = useAtomValue(userWithStateAtom(userId));

  if (state.loading) {
    return <div>読み込み中...</div>;
  }

  if (state.error) {
    return (
      <div>
        <p>エラーが発生しました</p>
        <code>{state.error.message}</code>
      </div>
    );
  }

  if (!state.data) {
    return <div>ユーザーが見つかりません</div>;
  }

  return (
    <div>
      <h2>{state.data.name}</h2>
      <p>{state.data.email}</p>
    </div>
  );
}

楽観的更新の実装

UI の応答性を高めるため、楽観的更新を実装してみましょう。

楽観的更新の atom

typescript// atoms/optimisticUpdate.ts
import { atom } from 'jotai';
import { trpc } from '@/utils/trpc';

// 楽観的更新を行う atom
export const optimisticUpdateUserAtom = atom(
  null,
  async (
    get,
    set,
    update: Partial<User> & { id: string }
  ) => {
    // 現在の値を保存(ロールバック用)
    const previous = get(userAtomFamily(update.id));

    // 即座に UI を更新(楽観的更新)
    set(userAtomFamily(update.id), {
      ...previous,
      ...update,
    });

    try {
      // サーバーに更新リクエストを送信
      const result = await trpc.user.update.mutate(update);

      // 成功したら、サーバーからの値で上書き
      set(userAtomFamily(update.id), result);

      return { success: true };
    } catch (error) {
      // 失敗したら元に戻す(ロールバック)
      set(userAtomFamily(update.id), previous);

      return { success: false, error };
    }
  }
);

楽観的更新により、ユーザーは即座にフィードバックを得られ、より快適な操作感が実現できますよ。

デバッグとエラーコード対応

開発中によく遭遇するエラーと、その解決方法をご紹介します。

エラー 1: TypeError: Cannot read property 'user' of undefined

発生条件: tRPC クライアントが初期化される前に atom が実行された場合に発生します。

解決方法:

typescript// atoms/user.ts(修正版)
export const userAtomFamily = atomFamily((userId: string) =>
  atom(async (get) => {
    // クライアントの存在チェックを追加
    const context = trpc.useContext();
    if (!context) {
      throw new Error('tRPC context not initialized');
    }

    const user = await context.user.getById.fetch({
      id: userId,
    });
    return user;
  })
);

エラー 2: Error 400: Bad Request - Invalid input

エラーコード: HTTP 400 エラーメッセージ:

luaTRPCClientError: Invalid input: Expected string, received number

発生条件: Zod スキーマと実際の入力値の型が一致していない場合に発生します。

解決方法:

#手順詳細
1スキーマの確認サーバー側の input() で定義した Zod スキーマを確認
2型の変換クライアント側で適切な型に変換してから送信
3TypeScript の活用型エラーが出ていないか確認

修正例:

typescript// 誤り:数値を文字列として送信していない
await trpc.user.getById.query({ id: 123 });

// 正しい:文字列に変換
await trpc.user.getById.query({ id: String(123) });

まとめ

Jotai と tRPC を組み合わせることで、サーバーからクライアントまで一貫した型安全性を保ちながら、柔軟で効率的な状態管理が実現できました。

本記事でご紹介した内容を振り返ってみましょう。

#ポイント効果
1tRPC の Router 型を活用エンドツーエンドの型安全性
2Jotai の atom で tRPC を呼び出し柔軟な状態管理と再利用性
3atomFamily によるキャッシュ効率的なデータ管理
4楽観的更新の実装優れた UX の実現

初期配線さえ整えてしまえば、あとは atom を追加していくだけで機能を拡張できます。型推論が強力に働くため、リファクタリングも安心して行えるのが魅力ですね。

特に、サーバー側で型を変更すると、クライアント側で即座に型エラーが検出されるため、バグを早期に発見できます。これにより、開発速度と品質の両方が向上するのです。

ぜひ、あなたのプロジェクトでも Jotai と tRPC の組み合わせを試してみてください。最初は設定が少し複雑に感じるかもしれませんが、一度環境を整えてしまえば、驚くほど快適な開発体験が待っていますよ。

関連リンク

;