😈

ぼくのかんがえたさいきょうのデータフェッチ 2021Summer🏄‍♂️【Next.js / Hasura】

2021/07/16に公開

フロントエンドアプリケーションの開発を行う上で避けては通れないデータフェッチの実装。
REST APIを使うか、GraphQLを使うか、クライアントでキャッシュするか、APIレスポンスにどのようにして型を付けるか、状態管理はどうするのかなど、開発者の悩みが尽きないけれども、それに関しての設計を考えたり議論を行うのはフロントエンド開発の楽しいポイントだと僕は思っています。

この記事では、バックエンドにHasura、フロントエンドにNext.jsを使用する場合に僕が最強だと感じたツールの組み合わせ・使い方を紹介します。

モチベーション

  • APIからのレスポンスにはTypeScriptの型が勝手についてきてほしい。asで型アサーションするのはやりたくない。
  • クライアントでもサーバー(SSR)でもデータフェッチの方法が同じインターフェースで提供されてほしい。
  • クライアントでAPIレスポンスをキャッシュし、不要なデータフェッチを防ぎたい。

前提

改めて、前提を整理します。

  • バックエンドにHasuraのGraphQLサーバーを使用。
  • フロントエンドにNext.jsを使用。SSR/SSGが必要とされる状況を想定します。
  • TypeScriptを使用。
  • ログイン機能のあるアプリケーションを想定。

また、今回説明に使用するコードでは以下のツールを使用します。それぞれの選定に深い意味はなく、この記事の本質とはあまり関係がありません。

状態管理

クライアントの状態管理にはRecoilを使用します。筆者が最も使い慣れているため。
Recoilを使わなくても同じなのですが、サーバーのデータの状態管理はReactQueryが行うので、RecoilとReactQueryの二重管理にならないように注意は必要です。
Recoilは"真にクライアントの状態"といえる値だけを管理するように努めましょう。例えば、UIのダークテーマ切り替えとか通知トースト表示のステータスなどです。これらはサーバーの状態が絡まない"真にクライアントの状態"といえるモノです。

認証

Auth0(auth0-react)を使用します。Hasuraのチュートリアルで使用されているため。

HasuraからのレスポンスにTypeScriptの型が勝手につくようにする

graphql-codegen/cliとそれらのプラグインを組み合わせて使うことで、GraphQLサーバーからスキーマを取得してTypeScriptの型定義が書かれたファイルを生成することができます。
graphql-codegenのツール群は、GraphQLを使って開発するなら絶対に知っておいたほうがいいと思うので、まだ知らない方は以下のドキュメントをサッと眺めてみることをおすすめします。
https://www.graphql-code-generator.com/docs/getting-started/installation

codegenの設定

今回は以下のようなcodegenの設定ファイルを用意しました。

codegen.js
const secrets = require('./secrets.json')

module.exports = {
  schema: [
    {
      'https://xxxxx-xxx-xx.hasura.app/v1/graphql': {
        headers: {
          'x-hasura-admin-secret': secrets.HASURA_ADMIN_SECRET,
          'x-hasura-role': 'user',
        },
      },
    },
  ],
  documents: ['./src/graphql/**/*.graphql'],
  overwrite: true,
  generates: {
    './src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-graphql-request',
      ],
      config: {
        skipTypename: false,
        withHooks: true,
        withHOC: false,
        withComponent: false,
      },
    },
    './graphql.schema.json': {
      plugins: ['introspection'],
    },
  },
}

ポイントとしては、スキーマの取得時に使用するHTTPリクエストヘッダーx-hasura-roleにアプリケーションで使用されるロールを指定することでしょうか。
Hasuraは、リクエスト時のロールによって見えるスキーマが変わります。
スキーマが変わるということは生成される型定義もロールによって変化するということです。
ここで不適切なロールを指定してしまうと、アプリケーションのユーザーが操作することを許可されていないqueryやmutationが生成されてしまうので注意が必要です。適切なロールを指定してください。
また、HASURA_ADMIN_SECRETは別ファイルにjsonで保持しておいて、リモートリポジトリにあがってしまうとまずいのでgitignoreにも記載しておきます。

プラグインのtypescript-operationstypescript-graphql-requestについては後述します。

アプリケーションで使用するqueryやmutationを配置する

codegen.jsdocumentで指定した階層(./src/graphql)にアプリケーションで使用するqueryやmutationが書かれたファイルを起きます。

getUserByName.graphql
query GetUserByName($name: String!) {
  users(where: { name: { _eq: $name } }) {
    name
  }
}

実際に開発を行うときは、ブラウザでHasuraのコンソールを開いてGraphiQLでレスポンスを確認しながら作業を進めていくはずです。
UIに必要とされるプロパティをHasuraコンソールのGraphiQLで選択していくと、queryが勝手に記述されていくので、それをコピペして./src/graphql配下に1クエリ単位でファイルを作っていくと良いと思います。

ここまでできたらpackage.jsonのscriptsにcodegenのスクリプトを設定しておきましょう。

"generate": "graphql-codegen --config codegen.js"
yarn generate

で実行し、型定義ファイルを生成しましょう!

codegen.jsgeneratesで指定した./src/generated/graphql.tsにTypeScriptの型がたくさん書かれているはずです。

これで型を吐き出すステップは完了です。

生成された型を使い、クライアント・サーバーから同じインターフェースでデータフェッチを行う

実はこの時点で「クライアント・サーバーからインターフェースでデータフェッチを行う」という目的は達成できています。
graphql-codegenプラグインのtypescript-operationstypescript-graphql-requestによって、./src/generated/graphql.tsgetSdkという名前の関数が吐かれているはずです。

これを使ってクライアントインスタンスを生成します。

src/lib/hasuraClient.ts
import { GraphQLClient } from 'graphql-request'
import { HASURA_GRAPHQL_END_POINT } from '../config/constants'
import { getSdk } from '../generated/graphql'

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const createHasuraClient = (token: string | null) => {
  const headers =
    token !== null
      ? {
          authorization: `Bearer ${token}`,
        }
      : undefined
  const client = new GraphQLClient(HASURA_GRAPHQL_END_POINT, {
    headers,
  })
  return getSdk(client)
}

export type HasuraClient = ReturnType<typeof createHasuraClient>

こんな感じでデータフェッチを行えます。

const hasuraClient = createHasuraClient(null)
hasuraClient.GetUserByName({ name: 'eringiv3' }).then((data) => {
  // dataにはGetUserByNameQueryという型がついている
  console.log({ data })
})

ここまでで、クライアントだけでしか使用することのできないhookなどに依存することなく、クライアント・サーバーから同じインターフェースで実行可能なHasuraクライアントのインスタンスが使えるようになりました。

最初に上げたモチベーションのうち、解決できていない課題は「クライアントでAPIレスポンスをキャッシュし、不要なデータフェッチを防ぎたい。」のみです。

クライアントでAPIレスポンスをキャッシュし、不要なデータフェッチを防ぎたい

ReactQueryを使います。恐らく、SWRやApollo Clientでも代用可能なんじゃないかなと思います。
ReactQueryの詳細な説明はしませんが、おおざっぱに説明するとAPIリクエストのレスポンスをクライアントでキャッシュし、キャッシュが有効であればリクエストを発行せずキャッシュを返すというものです。

キャッシュキーについて考える必要があります。
キャッシュキーは、実行するGraphQLのクエリ、引数のvariables、認証状態ごとに一意の値となってほしいです。
認証状態が必要なのは、前述の通りHasuraはロールによって実行可能なクエリが変化するためです。

クエリ、引数のvariables、認証状態を引数にとって一意なキャッシュキーを生成するユーティリティ関数を用意します。

src/utils/stringHelpers.ts
import hash from 'hash.js'

// eslint-disable-next-line @typescript-eslint/ban-types
export const getQueryKey = (fn: Function, variables: object): string => {
  return `${fn.name}::${sha256(JSON.stringify(variables))}`
}

export const sha256 = (str: string): string => {
  return hash.sha256().update(str).digest('hex')
}

こんな感じのキャッシュキーができます。リクエストごとに発行されたキャッシュキーはReactQueryのdevtoolから閲覧可能です。

これを使うとクライアントからは以下のような感じでデータフェッチが可能です!

src/pages/index.tsx
import { useAuth0 } from '@auth0/auth0-react'
import { useQuery } from 'react-query'

const SomeComponent: React.FC = () => {
    const { isAuthenticated } = useAuth0()
    const hasuraClient = createHasuraClient(null)
  const variables = { name: 'eringiv3' }
  const { data, error, isLoading } = useQuery(
    getQueryKey(hasuraClient.GetUserByName, {
      variables,
      isAuthenticated,
    }),
    () => hasuraClient.GetUserByName(variables)
  )
  
  if (isLoading) return <div>Loading...</div>
  
  return <div>{data.users[0].name}</div>
}
export default SomeComponent

データフェッチのために使われた行数は10行にも達していません。
かつ、ReactQueryのおかげでレスポンスのキャッシュ、宣言的なデータフェッチが可能になっています。

今回はサーバー・クライアントから同じインターフェースでクエリを実行できるようにしたいというモチベーションがあったのでこのような方法を取りましたが、サーバーサイドでデータフェッチを行うことがないのならばtypescript-react-queryというgraphql-codegenプラグインを使うと同じようなことがより楽に実現できそうです(試してないです)。
https://www.graphql-code-generator.com/docs/plugins/typescript-react-query

これで終わりかと思いきや、上記の例ではまだ不足があります。
Hasuraのロールを効かせるためには、ログイン状態のとき、リクエストヘッダーにトークンを指定する必要があります。
コンポーネントごとに以下のような感じで毎度ヘッダーを個別に指定するのはちょっとめんどくさいです。

  const { isAuthenticated, getAccessTokenSilently } = useAuth0()
  const { data, error, isLoading } = useQuery(
    getQueryKey(hasuraClient.GetUserByName, {
      variables,
      isAuthenticated,
    }),
    () =>
      isAuthenticated
        ? getAccessTokenSilently().then((token) =>
            hasuraClient.GetUserByName(variables, {
              authorization: `Bearer ${token}`,
            })
          )
        : hasuraClient.GetUserByName(variables)

最初の方にでてきたcreateHasuraClient関数でクライアントインスタンスを作るときにデフォルトのヘッダーの指定ができます。
これを利用して、認証状態によってhasuraClientを差し替える処理を行います。

src/pages/_app.tsx
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react'
import type { AppProps } from 'next/app'
import { useEffect } from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { ReactQueryDevtools } from 'react-query/devtools'
import { RecoilRoot, useSetRecoilState } from 'recoil'
import {
  AUTH0_API_AUDIENCE,
  AUTH0_CLIENT_ID,
  AUTH0_DOMAIN,
  AUTH0_REDIRECT_URI,
} from '../config/constants'
import { createHasuraClient } from '../lib/hasuraClient'
import { hasuraClientState } from '../states/hasuraClient'
const queryClient = new QueryClient()

const AppInit = () => {
  const { isAuthenticated, getAccessTokenSilently } = useAuth0()
  const setHasuraClient = useSetRecoilState(hasuraClientState)

  useEffect(() => {
    if (isAuthenticated) {
      getAccessTokenSilently().then((token) => {
        const client = createHasuraClient(token)
        setHasuraClient(client)
      })
    } else {
      const client = createHasuraClient(null)
      setHasuraClient(client)
    }
  }, [isAuthenticated])

  return null
}

const App: React.FC<AppProps> = ({ Component, pageProps }) => {
  return (
    <Auth0Provider
      domain={AUTH0_DOMAIN}
      clientId={AUTH0_CLIENT_ID}
      redirectUri={AUTH0_REDIRECT_URI}
      audience={AUTH0_API_AUDIENCE}
    >
      <QueryClientProvider client={queryClient}>
        <RecoilRoot>
          <AppInit />
          <Component {...pageProps} />
          <ReactQueryDevtools initialIsOpen={false} />
        </RecoilRoot>
      </QueryClientProvider>
    </Auth0Provider>
  )
}

export default App
src/states/hasuraClient.ts
import { atom } from 'recoil'
import type { HasuraClient } from '../lib/hasuraClient'
import { createHasuraClient } from '../lib/hasuraClient'

export const hasuraClientState = atom<HasuraClient | undefined>({
  key: 'hasuraClient',
  default: createHasuraClient(null),
})

これでコンポーネントごとに個別でトークンを取得する処理を書く必要はなくなりました!

最終的にクライアント・サーバーでそれぞれ以下のような感じでデータフェッチを行えるようになりました。
当然すべて型がついた状態となっています。

サーバー
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { GetUserByNameQuery } from '../../generated/graphql'
import { createHasuraClient } from '../../lib/hasuraClient'

const MyPage: React.VFC<
  InferGetServerSidePropsType<typeof getServerSideProps>
> = ({ data }) => {
  const user = data.users[0]
  return <div>{user.name}</div>
}

export default MyPage

type ServerSideProps = {
  data: GetUserByNameQuery
}
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async () => {
  const hasuraClient = createHasuraClient(null)
  const data = await hasuraClient.GetUserByName({ name: 'eringiv3' })
  if (data.users.length === 0) {
    return {
      notFound: true,
    }
  }
  return {
    props: {
      data,
    },
  }
}
クライアント
import { useAuth0 } from '@auth0/auth0-react'
import type { NextPage } from 'next'
import { useQuery } from 'react-query'
import { useRecoilValue } from 'recoil'
import LoginButton from '../components/auth/LoginButton'
import LogoutButton from '../components/auth/LogoutButton'
import { hasuraClientState } from '../states/hasuraClient'
import { getQueryKey } from '../utils/stringHelpers'

const IndexPage: NextPage = () => {
  const hasuraClient = useRecoilValue(hasuraClientState)
  const { user, isAuthenticated } = useAuth0()
  const variables = { name: 'eringiv3' }

  const { data, isLoading } = useQuery(
    getQueryKey(hasuraClient.GetUserByName, {
      variables,
      isAuthenticated,
    }),
    () => hasuraClient.GetUserByName(variables)
  )

  if (isLoading) return <div>Loading...</div>

  return isAuthenticated ? (
    <div>
      <div>
        <img src={user.picture} alt={user.name} />
        <h2>{data.users[0].name}</h2>
      </div>
      <div>
        <LogoutButton />
      </div>
    </div>
  ) : (
    <LoginButton />
  )
}

export default IndexPage

以上が僕の考えた最強のデータフェッチ2021Summerです。
いかがだったでしょうか?

あとがき

異論やご指摘は大歓迎です。お待ちしております。
みなさんの思う最強のデータフェッチを教えて下さい。

参考にさせていただいた記事

https://techlife.cookpad.com/entry/2021/03/24/123214

Discussion