🎑

TanStack Query + Clerk + oRPC で cross-origin なバックエンドにリクエストを送りたい

に公開

どうやら Zennfes というコンテストをやっているらしい、備忘録も兼ねてインターネットに放流する。

始めに

  • TanStack はいいぞ
  • oRPC はいいぞ
  • Clerk はいいぞ

型安全で快適な開発になったのでこの3つの開発者には足を向けて寝れませんね。
スターもお忘れずに。

環境のお話し

  • フロントエンド
    • TanStack Router (React)
    • TanStack Query
  • バックエンド
    • Hono

フロントエンド・バックエンド間の通信には oRPC を用いた通信を、認証には Clerk を採用している。

やりたいこと

TanStack Query からリクエストをバックエンド(cross-origin)に送りたい。
リクエストには oRPC を採用しており、oRPC のヘッダーに Clerk から取ったJWKを入れたい。

素直に公式通りにやる

oRPC のヘッダーにトークンを入れるには Client Context を用いることが書かれている。
https://orpc.unnoq.com/docs/client/rpc-link#using-client-context

Cross-Origin 環境下においてリクエストを Clerk + TanStack Query で作る場合は以下のように紹介されている。
https://clerk.com/docs/backend-requests/making-requests#react-based-applications-2

以上のことから、次のようなコードを作ることができる。

utils.ts
import { RPCLink } from "@orpc/client/fetch"
import { createORPCClient } from "@orpc/client"
import type { Router } from "@cian-diary/backend/orpc"

export type ClientContext = {
  token: string | null
}

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5174/"

export const link = new RPCLink<ClientContext>({
  url: API_URL,
  headers: ({ context }) => ({
    ...(context.token && { Authorization: `Bearer ${context.token}` })
  })
})

export const client: Router<ClientContext> = createORPCClient(link)

フロントエンドでは await useAuth().getToken() でトークンを取得したうえで、Client Context にトークンを流し込む形になる。
ただ、それだけでは Clerk の初期化が終わってない段階でリクエストが送られることがあり、enabled オプションで初期化・ログインが済んでいることを確認したうえでリクエストを送ることにした。

[pages].tsx
import { useAuth } from "@clerk/clerk-react"
import { client, useQueryWithAuth } from "./utils"
import { createFileRoute } from "@tanstack/react-router"

export const Route = createFileRoute("/[pages]")({
  component: Pages
})

function Pages() {
  const { isLoading, isSignedIn, getToken } = useAuth()
  const query = useQuery({
    queryKey: ["test"],
    queryFn: (async () => await client.[route].get({}, { context: { token: await getToken() }})), // 呼び出し時に Context に先ほど取得したトークンを挿入する
    enabled: isLoaded && isSignedIn // Clerk のクライアントで初期化が済んでおり、サインインしているときに実行
  )}

  return <div>{JSON.stringify(query.data)}</div>
}

ここまでが公式のやり方であり、ここまで見たあなたはこう思うだろう。
「useAuthとuseQueryがあるからカスタムフックが作れるな」と。

ではここからカスタムフックを作っていこうと思う。

カスタムフックを作る

utils.ts
// [重複箇所省略]

import { useAuth } from "@clerk/clerk-react"
import {
  useQuery,
  useMutation,
  type UseQueryOptions,
  type UseMutationOptions,
  type QueryKey
} from "@tanstack/react-query"

export const useQueryWithAuth = <
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>(
  params: Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, "queryFn"> & {
    queryFn: (context: ClientContext) => Promise<TQueryFnData>
  }
) => {
  const { isLoaded, isSignedIn, getToken } = useAuth()

  const query = useQuery<TQueryFnData, TError, TData, TQueryKey>({
    ...params,
    queryFn: async () => params.queryFn({ token: await getToken() }),
    enabled: isLoaded && isSignedIn && params.enabled
  })

  return query
}
[pages].tsx
import { client, useQueryWithAuth } from "@/lib/orpc"
import { createFileRoute } from "@tanstack/react-router"

export const Route = createFileRoute("/test")({
  component: Test
})

function Test() {
  const query = useQueryWithAuth({
    queryKey: ["test"],
    queryFn: async ({ token }) => await client.posts.list({}, { context: { token } })
  })

  return <div>{JSON.stringify(query.data)}</div>
}

UseQueryOptions 型の定義の一部(queryFn)を拡張した。
コンポーネント側からは以下のような形で認証トークンを受け取ってリクエストを送ることができる。

const query = useQueryWithAuth({
    queryKey: ["test"],
    queryFn: async ({ token }) => await client.posts.list({}, { context: { token } })
  })

useAuth が無くなったね!コードがすっきりした!

終わり。

改善点

型エラーとは遭遇しないし、現時点で正常に動作している。
型についても TanStack Query の本来の型を拡張しているが、所詮は独自実装であり、改善の余地があると考える。

以下の2点を改善したい。

  • 独自に型の拡張をせず、どうにかする方法が欲しい
  • oRPC に Client Context を自動的に渡したい

どなたか知見がある人が居ればコメントかTwitter DM (@tukimin_vrc)に投げていただきたい。

Discussion