👻

jotai-tanstack-query + tRPCを使ったデータ取得ロジックの作成

2023/08/17に公開

はじめに

こんにちは。りょと申します。初投稿です。
どういうことを投稿すればいいかわからないし間違っていること投稿してたらどうしようという不安がありながらも、とりあえず発信してみよう!ということで、
最近取り組んだjotai-tanstack-queryとtRPC周りの話を投稿しようかなと思います。
エンジニア歴も浅いので、間違ったこと言ってたりもっといいやり方あるよ!とかがあったりすれば、その際はコメントで優しく教えていただけますと幸いです^^

参考

Teruhisa.dev様 (@t6adev)の以下ツイートを参考にさせていただきました。
https://twitter.com/t6adev/status/1616245305640652800?s=20

経緯

jotaiとtRPCを組み合わせてスマートな状態管理とAPI通信を実現したいなぁ
jotai-trpcなるものがあるらしい、これを使うか
→→どうやら@trpc/clientベースなのでtanstackの機能が使えないようだ🤔
→→→上のツイートに辿り着き、jotai-tanstack-query@trpc/clientを組み合わせたやり方で実装することに

やりたいこと

  • DBに入っている商品情報を条件を絞って取得したい
  • 今回は商品一つ一つに製作者の情報がある想定なので、選択した製作者の商品を表示する機能をつけたい
  • 並べ替え機能、検索機能、フィルター機能、ページ送り機能をつけたい

実装

前提

まず、前提として今回は以下のような商品用テーブルproductを使うこととします。 (実際はproducerは別テーブルに切り出しているが面倒なので一旦これで。)

name price amount producer
商品名A 1000 10 太郎
商品名B 2000 5 花子

ライブラリのインストール手順などは省略するので適宜インストールしてください。

環境

  • React + Vite
  • Zod
  • Express
  • tRPC

フロント側

必要なatomを以下のように作成します。

productAtoms.ts
import { atom } from 'jotai';
import { atomsWithQuery } from 'jotai-tanstack-query';

import { TrpcOut, trpc } from '../trpc';

type Products = TrpcOut['getProducts'];
type SortedKey = 'name' | 'price' | 'amount' | 'producer';

// 製作者の状態を保存しておくatom
export const producerIdAtom = atom('all');
// ソートする基準のキーを保存しておくatom
export const sortedKeyAtom = atom<SortedKey>('name');
// 検索するキーワードを保存しておくatom
export const searchKeywordAtom = atom('');
// ページ番号を保存しておくatom
export const paginationAtom = atom(1);

// tRPCでデータを取得する
export const [, productsQueryAtom] = atomsWithQuery<Products>((get) => ({
  queryKey: ['getProducts', get(producerIdAtom), get(sortedKeyAtom), get(paginationAtom)],
  queryFn: async () => {
    const products = await trpc.getProducts.query({
      producerId: get(producerIdAtom),
      sortedKey: get(sortedKeyAtom),
      searchKeyword: get(searchKeywordAtom),
      pagination: get(paginationAtom)
    });
    return products;
  },
  initialData: []
}));

やっていることとしては、指定の製作者やソートするキー、検索キーワードやページ番号などをtRPCのgetProductsのinputに渡して該当する商品情報をDBから取得しています。 (API側の実装は後述)
こうしておくと、各componentからグローバルにproducerIdsortedKeyの値を参照することができ、それらの値が変わったタイミングでrefetchが自動で走りデータがリアルタイムで更新できるようになります。
何よりjotaiを使うことで、atomベースでこの辺りのロジックが記述できるので、グローバルなstateとそれに係る処理がまとまっていて見やすいし、わざわざカスタムフックを用意してuseCallbackuseEffectを書きまくる必要もなくなるのは大きなメリットだと思います。

ちなみに余談ですがqueryKeysearchKeywordAtomを指定していない理由は、検索フォームで文字を1つ入力する度にデータ取得が走るのを防ぐためです。

各componentでこれらの値を参照する場合はuseAtomを用いることで、React標準装備のuseStateのように値をコントロールすることができます。

import { producerIdAtom } from 'path/to/productAtoms';

const [producerId, setProducerId] = useAtom(producerIdAtom);

これをinputのonChangeなどで使っていく感じ。

また、atomsWithQueryによって取得されるデータや通信に関する状態などは以下のようにuseAtomValueを用いることでアクセスすることができます。

import { productsQueryAtom } from 'path/to/productAtoms';

const { data, isFetching, isSuccess, isError } = useAtomValue(productsQueryAtom);

あと、

import { TrpcOut, trpc } from '../trpc';

の部分ですが、こちらはtRPCクライアントを引っ張ってきているものです。とりあえず以下のような感じで実装しました。

trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import SuperJSON from 'superjson';

// サーバー側で定義されたtRPCのrouter
import type { AppRouter } from '@server/app';

import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';

// 各routerで定義されたinputの型を参照
export type TrpcIn = inferRouterInputs<AppRouter>;
// 各routerで定義されたoutputの型を参照
export type TrpcOut = inferRouterOutputs<AppRouter>;

// tRPCクライアントを作成
export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: import.meta.env.VITE_TRPC_URL || './trpc',
      fetch: (url, options) => fetch(url, { ...options, credentials: 'include' })
    })
  ],
  transformer: SuperJSON
});

サーバー側

続いてサーバー側でtRPCクライアント経由で受け取ったinputを基にprismaを使ってDBからデータを取得する部分です。
以下のようにルータを定義します。

app.ts
import { z } from 'zod';

import { procedure, router } from './trpc';

// 商品取得時のinputのzodスキーマ定義
const GetProductInputSchema = z.object({
  producerId: z.string(),
  sortedKey: z.enum(['name', 'price', 'amount', 'producer']),
  searchKeyword: z.string(),
  pagination: z.number().min(1)
});

export const appRouter = router({
  getProducts: procedure.input(GetProductInputSchema).query(async ({ input, ctx }) => {
    // producerIdがallの場合、全ての製作者の商品を取得する
    const idWhere = input.producerId === 'all' ? {} : { producerId: input.producerId };
    const products = await ctx.prisma.product.findMany({
      where: {
        AND: [
          idWhere,
          { name: { contains: input.searchKeyword } }
        ]
      },
      // 1ページあたり10商品を表示する設定
      skip: 10 * (input.pagination - 1),
      take: 10,
      orderBy: [{ [input.sortedKey]: 'asc' }]
    });
    return products;
  })
});

export type AppRouter = typeof appRouter;

input(GetProductInputSchema)の部分でzodを使ったinputの型判定をしています。
後はprismaの各パラメータにそれぞれの条件を与えてあげるだけでいいので非常に簡単ですね。
1ページあたりの商品を変えたければそこもパラメータ化して10の部分に入れればいいし、
昇順降順とかも変えたければパラメータ化してzodの定義に加えてあげればいいかなと思います。
その場合は煩雑にならないようなスキーマ定義にするよう気をつけましょう。

ちなみにserver側のtRPC設定ファイルは以下のように実装しました。

trpc.ts
import { PrismaClient } from '@prisma/client';
import { inferAsyncReturnType, TRPCError, initTRPC } from '@trpc/server';
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
import superjson from 'superjson';

// prismaクライアントを定義しtRPCのコンテキストに持たせる
const prisma = new PrismaClient({
  log: ['error', 'warn', 'info'],
  datasources: {
    db: {
      url: process.env.DATABASE_URL || ''
    }
  }
});

const createContext = async ({ req, res }: CreateExpressContextOptions) => {
  return { req, res, prisma };
};
type Context = inferAsyncReturnType<typeof createContext>;

const t = initTRPC.context<Context>().create({
  transformer: superjson
});

export const router = t.router;
export const procedure = t.procedure;

後はこれをexpress側で

server.ts
  app.use('/trpc', createExpressMiddleware({ router: appRouter, createContext }));

のようにAPIのエンドポイントを設定してあげればOKです。

まとめ

だいぶ雑ですが、自分はこんな感じで実装しました、的なところでjotai-tanstack-query + trpcの実装について書いてみました。
何かアドバイスや疑問点などあれば是非教えてください!!

これからも何かしら発信していけるよう頑張ります('ω')

リンク

jotai-tanstack-query
tRPC

Discussion