🐈

VanillaTSで型安全にGraphQL通信する

に公開

株式会社MyVisionのフロントエンドエンジニアのsrkwです。
今回、弊社のHPのフォーム部分をAstro+Reactの高速SPAに置き換えるにあたり、GraphQL Codegenを使ったGraphQL通信の抽象化を行ったので、事例紹介をしたいと思います。

なお、Astro化の全貌については以下の記事に記載したので、ご興味があればぜひこちらもご覧ください。

https://zenn.dev/my_vision/articles/f3dcb3e5f04b21

背景・概要

弊社のアプリケーションのFE・BE間の通信にはGraphQLを利用しています。
社内の他のFEリポジトリではApolloClient+graphql-codegenを使って通信処理の抽象化・型補完を行っているのですが、今回のプロジェクトではバンドルサイズ軽量化のためにApolloClientの採用を見送ったため、VanillaTS製のヘルパー関数とgraphql-codegenを使って、できるだけ開発コストを小さく保ちながらGraphQLサーバーと通信する仕組みを実装しました。

ヘルパー関数の実装

通信の抽象化のために、以下の3つの関数を実装しました。
useFetchGql useLazyFetchGql については、apollo clientの useQuery useLazyQuery とインターフェースを似せることで、社内の他のNext.js+ApolloClient構成のプロジェクトとの開発体験の差分が小さくなるようにしています。

src/graphql/utils.ts
/**
 * fetchを使ってGraphQLリクエストを実行する関数 \
 * カスタムhookを使えないastroファイル、loadingやerrorの状態を管理する必要がない箇所などで利用することを想定している
 * @param query
 * @param variables
 * @param handleError
 * @returns
 */
export const fetchGql = async <TResult, TVariables>(
  query: TypedDocumentString<TResult, TVariables>,
  variables: TVariables,
  handleError?: (error: unknown) => void
) => {
  const startTime = dayjs()

  try {
    const response = await fetch(
      `${import.meta.env.PUBLIC_API_BASE_URL}/graphql/`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/graphql-response+json',
        },
        body: JSON.stringify({
          query,
          variables,
        }),
      }
    )

    if (!response.ok) {
      console.error(`error: ${await response.json()}`)
      throw new Error('Network response was not ok')
    }

    const result = (await response.json()) as ExecutionResult<TResult>
    return result.data
  } catch (error) {
    handleError?.(error)
    console.error(`error: ${error}`)
  } finally {
    console.log(`operation: ${query}\ntime: ${dayjs().diff(startTime)}`)
  }
}

/**
 * fetchを使ってGraphQLリクエストを実行するカスタムhook \
 * queryの実行のみを想定している。 \
 * queryおよびvariablesが変化した場合に自動で再実行される。
 * @param query
 * @param variables
 */
export const useFetchGql = <TResult, TVariables>(
  query: TypedDocumentString<TResult, TVariables>,
  variables: TVariables
) => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<unknown>()
  const [result, setResult] = useState<TResult>()

  const handleExecute = useCallback(async () => {
    setLoading(true)

    const result = await fetchGql(query, variables, setError)
    setResult(result ?? undefined)
    setLoading(false)
  }, [query, JSON.stringify(variables)])

  useEffect(() => {
    handleExecute()
  }, [handleExecute])

  return { loading, error, data: result }
}

/**
 * fetchを使ってGraphQLリクエストを実行するカスタムhook \
 * mutationを実行することを想定している
 * @param query
 * @param variables
 * @returns
 */
export const useLazyFetchGql = <TResult, TVariables>(
  query: TypedDocumentString<TResult, TVariables>
) => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<unknown>()
  const [result, setResult] = useState<TResult>()

  const handleExecute = useCallback(
    async (variables: TVariables) => {
      setLoading(true)

      const result = await fetchGql(query, variables, setError)
      setResult(result ?? undefined)
      setLoading(false)
      return result
    },
    [query]
  )

  return [handleExecute, { loading, error, data: result }] as const
}

graphql-codegen用の設定

GraphQLサーバー側で実装されたスキーマ定義をもとに、FEで記述したクエリ・ミューテーション・フラグメント定義をソースとして graphql-codegen を使って型定義・ドキュメント定義・ヘルパー関数を自動生成します。
これにより、BE側のスキーマ更新に対して型安全性を担保しています。

今回実装するプロジェクトでは通信の抽象化関数にApolloClientやSWRなどのライブラリを利用しないため、VanillaTSのサンプルを参考にcodegen用の設定を実装しました。

https://the-guild.dev/graphql/codegen/docs/guides/vanilla-typescript

上記のサンプルの通りpresetにはClientを指定していますが、graphql-codegenが現状.astroファイル内のGraphQLドキュメント定義を認識できない(関連していそうなIssue)都合上、ClientPresetが推奨しているfragment colocateパターンは採用していません。

参考:

https://the-guild.dev/graphql/hive/blog/unleash-the-power-of-fragments-with-graphql-codegen

https://zenn.dev/moneyforward/articles/20221211-fragment-colocation

また、fragment colocateを採用しない環境下でメリットを最大限享受できなさそうなのと、FragmentMaskingを利用するほどSPA内で参照するプロパティが多くないため、FragmentMaskingについても利用していません。

codegen.ts
import { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: 'http://localhost:3000/graphql',
  documents: ['src/graphql/docs/index.ts'],
  ignoreNoDocuments: true,
  generates: {
    './src/graphql/generated/': {
      preset: 'client',
      presetConfig: {
        fragmentMasking: false,
      },
      config: {
        documentMode: 'string',
      },
    },
    './schema.graphql': {
      plugins: ['schema-ast'],
      config: {
        includeDirectives: true,
      },
    },
  },
}

export default config

TSのGraphQL用LSPプラグインの設定

graphql-codegenの公式Docでも紹介されている、以下のTSのGraphQL用LSPプラグインを利用します。

https://github.com/0no-co/GraphQLSP

tsconfig.json に以下の内容を記述します。

tsconfig.json
{
  "compilerOptions": {
    ...
    "plugins": [
      {
        "name": "@0no-co/graphqlsp",
        "schema": "./schema.graphql"
      }
    ]
  },
}

開発体験

上記の ヘルパー関数の実装graphql-codegen用の設定 を踏まえて、通信関連の開発体験としては以下のような感じになります。

Fragment,Query,Mutationの定義

各コンポーネントで利用するFragment/Queryを記述します。
Fragment/Queryについては自動生成された graphql という関数でラップすることで、BEのスキーマ定義に沿った定義ができます。

src/graphql/docs/index.ts
import { graphql } from 'src/graphql/generated'

export const EducationFragment = graphql(`
  fragment Education on Education {
    id
    name
    kana
  }
`)

export const publicListEducationQuery = graphql(`
  query publicListEducation {
    publicListEducation {
      ...Education
    }
  }
`)

export const logFormAccessMutation = graphql(`
  mutation logFormAccess($input: LogFormAccessInput!) {
    publicLogFormAccess(input: $input) {
      success
    }
  }
`)

記述中は @0no-co/graphqlsp の恩恵として、エディタ上で各種入力候補とその型定義が表示されます。

codegenの実行

graphql-codegen --config codegen.ts を実行することで、型定義とヘルパー関数が自動で生成されます。

あるいはFragment,Queryの記述時に graphql-codegen --config codegen.ts --watch を実行しておくことで、コードの変更を検知して自動生成を走らせることもできます。

定義したQuery/Mutationの利用

定義したQuery/Mutationは、ヘルパー関数の実装 で実装したヘルパー関数を使って呼び出すことができます。

EducationList.tsx
import { EducationFragment, publicListEducationQuery } from 'src/graphql/docs'
import { getFragmentData } from 'src/graphql/generated'
import { useFetchGql } from 'src/graphql/utils'

export const EducationList = () => {
  const { data, loading } = useFetchGql(publicListEducationQuery, {})

  const educationList =
    data?.publicListEducation.map((education) =>
      getFragmentData(EducationFragment, education)
    ) || []

まとめ

この構成では

  • documentの定義
  • codegen
  • ヘルパー関数を使った呼び出し

の3つをやれば、型安全にGraphQLサーバーと通信することができます。
わりとありふれた実装のような気がしますが、今後同じようなことをやろうとする誰かの参考になれば嬉しいです。

MyVision技術ブログ

Discussion