🛰️

Apollo Client (React)で GraphQL Query の実行タイミングを複数のコンポーネント間で制御する

2023/12/03に公開

こんにちは、アルダグラムでエンジニアをしている松田です。
本記事は株式会社アルダグラム Advent Calendar 2023 3日目の記事です。

Apollo Client のLazyQueryでは、任意のタイミングでクエリを実行することができます。
また、fetchMoreを用いることで、追加のリクエストによるページネーションを実現することができます。

ただ、これらの機能を利用する際は、クエリの実行タイミングに注意を払う必要があります。
コンポーネントのライフサイクル次第で、意図しないタイミングでクエリが実行されてしまうと、パフォーマンスの低下やサーバーへの負荷増大を招く可能性があるからです。

本稿では、クエリの実行タイミングを、Reactの複数のコンポーネント間で制御する方法を紹介します。

方針

Reactで複数コンポーネント間でデータ共有する場合は、contextを利用するのが一般的です。
contextを用いて、以下のような方針で実装を進めます。

  1. lazyQueryHookの戻り値のcontextを作成する
  2. クエリ実行を制御するトリガーのcontextを作成する
  3. 各種contextをまとめて提供するProvider componentを作成する
  4. Provider component内で、lazyQueryHookを利用する
  5. リクエストが必要なコンポーネントで、クエリの実行を制御する

実装

以下の例では、 FetchUsersQuery というクエリを用いることを想定して、実装を進めます。

lazyQueryHookの戻り値のcontextを作成する

まず、lazyQueryHookの戻り値を格納するcontextを作成します。

import { createContext, useContext } from 'react'
import { FetchUsersQuery } from 'path/to/graphql'

// lazyQueryHookの戻り値を格納するContext
const Data = createContext<FetchUsersQuery | undefined>(undefined)
const Loading = createContext<boolean | undefined>(undefined)

// 各種Contextを参照するためのHooks
export const useFetchUsersData = () => useContext(Data)
export const useFetchUsersLoading = () => useContext(Loading)

クエリ実行を制御するトリガーのcontextを作成する

次に、クエリ実行を制御するトリガーのcontextを作成します。
このcontextには、クエリ実行を制御するためのSetStateを格納します。

import {
  Dispatch,
  SetStateAction,
  createContext,
  useContext,
} from 'react'

// Query実行のトリガーを格納するContext
const FetchFirstTrigger = createContext<Dispatch<SetStateAction<boolean>>>(
  () => undefined
)
const FetchMoreTrigger = createContext<Dispatch<SetStateAction<boolean>>>(
  () => undefined
)

// Contextを参照するためのHooks
export const useFetchUsersFetchFirst = () => useContext(FetchFirstTrigger)
export const useFetchUsersFetchMore = () => useContext(FetchMoreTrigger)

各種contextをまとめて提供するProvider componentを作成する

次に、作成したcontextをまとめて提供するProvider componentを作成します。
childrenに当たる子要素で、各種contextが参照可能となります。

import {
  Dispatch,
  FC,
  ReactNode,
  SetStateAction,
  createContext,
  useContext,
  useState
} from 'react'
import {
  FetchUsersQuery,
} from 'path/to/graphql'

// lazyQueryHookの戻り値を格納するContext
const Data = createContext<FetchUsersQuery | undefined>(undefined)
const Loading = createContext<boolean | undefined>(undefined)

// Query実行のトリガーを格納するContext
const FetchFirstTrigger = createContext<Dispatch<SetStateAction<boolean>>>(
  () => undefined
)
const FetchMoreTrigger = createContext<Dispatch<SetStateAction<boolean>>>(
  () => undefined
)

// Contextを参照するためのHooks
export const useFetchUsersData = () => useContext(Data)
export const useFetchUsersLoading = () => useContext(Loading)
export const useFetchUsersFetchFirst = () => useContext(FetchFirstTrigger)
export const useFetchUsersFetchMore = () => useContext(FetchMoreTrigger)

type LazyQueryProviderProps = {
  children?: ReactNode
}

export const FetchUsersLazyQueryProvider: FC<LazyQueryProviderProps> = ({
  children
}) => {
  // Query実行のトリガーするState
  const [shouldFetchFirst, setShouldFetchFirst] = useState(false)
  const [shouldFetchMore, setShouldFetchMore] = useState(false)

  return (
    <Data.Provider value={data}>
      <Loading.Provider value={loading}>
        <FetchFirstTrigger.Provider value={setShouldFetchFirst}>
          <FetchMoreTrigger.Provider value={setShouldFetchMore}>
            {children}
          </FetchMoreTrigger.Provider>
        </FetchFirstTrigger.Provider>
      </Loading.Provider>
    </Data.Provider>
  )
}

Provider component内で、lazyQueryHookを利用する

Provider component内で、lazyQueryHookを利用します。
この際、useEffectを用いて、shouldFetchFirstやshouldFetchMoreの値が変更された際に、クエリを実行するようにします。

shouldFetchFirstをtrueにすることで、初回のクエリが実行されます。
shouldFetchMoreをtrueにすることで、追加のクエリ(ページネーション)が実行されます。

これで、children以下に相当する子孫要素にて、shouldFetchFirstやshouldFetchMoreの値を変更することで、クエリの実行タイミングを制御することができるようになりました。

import { LazyQueryHookOptions, OperationVariables } from '@apollo/client'
import {
  Dispatch,
  FC,
  ReactNode,
  SetStateAction,
  createContext,
  useContext,
  useEffect,
  useState
} from 'react'
import {
  FetchUsersQuery,
  useFetchUsersLazyQuery
} from 'path/to/graphql'

// lazyQueryHookの戻り値を格納するContext
const Data = createContext<FetchUsersQuery | undefined>(undefined)
const Loading = createContext<boolean | undefined>(undefined)

// Query実行のトリガーを格納するContext
const FetchFirstTrigger = createContext<Dispatch<SetStateAction<boolean>>>(
  () => undefined
)
const FetchMoreTrigger = createContext<Dispatch<SetStateAction<boolean>>>(
  () => undefined
)

// Contextを参照するためのHooks
export const useFetchUsersData = () => useContext(Data)
export const useFetchUsersLoading = () => useContext(Loading)
export const useFetchUsersFetchFirst = () => useContext(FetchFirstTrigger)
export const useFetchUsersFetchMore = () => useContext(FetchMoreTrigger)

type LazyQueryProviderProps = {
  lazyQueryHookOptions?: LazyQueryHookOptions // optionを指定できるようにしておくと、Provider設置時に指定できる
  children?: ReactNode
}

export const FetchUsersLazyQueryProvider: FC<LazyQueryProviderProps> = ({
  lazyQueryHookOptions = {
    fetchPolicy: 'network-only',
    nextFetchPolicy: 'cache-first'
  },
  children
}) => {
  // lazyQueryHookを利用
  const [
    fetchUsers,
    { called, data, loading, fetchMore }
  ] = useFetchUsersLazyQuery(lazyQueryHookOptions)

  // Query実行のトリガーするState
  const [shouldFetchFirst, setShouldFetchFirst] = useState(false)
  const [shouldFetchMore, setShouldFetchMore] = useState(false)

  // 初回のFetch
  useEffect(() => {
    // 初回Fetchを実施したいタイミングで、shouldFetchFirstをtrueにする
    if (!called && shouldFetchFirst && fetchUsers) {
      fetchUsers()
      setShouldFetchFirst(false)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [shouldFetchFirst])

  // 追加のFetch
  useEffect(() => {
    // 追加Fetchを実施したいタイミングで、shouldFetchMoreをtrueにする
    if (!loading && shouldFetchMore && fetchMore) {
      fetchMore()
      setShouldFetchMore(false)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [shouldFetchMore, loading])

  return (
    <Data.Provider value={data}>
      <Loading.Provider value={loading}>
        <FetchFirstTrigger.Provider value={setShouldFetchFirst}>
          <FetchMoreTrigger.Provider value={setShouldFetchMore}>
            {children}
          </FetchMoreTrigger.Provider>
        </FetchFirstTrigger.Provider>
      </Loading.Provider>
    </Data.Provider>
  )
}

リクエストが必要なコンポーネントで、クエリの実行を制御する

FetchUsersLazyQueryProviderのchildren以下に相当する子孫要素であれば、どのコンポーネントでもクエリの実行タイミングを制御することができます。

次に示す例では、以下のようなコンポーネント構成に、先程作成したFetchUsersLazyQueryProviderとそれに関連するHooksを適用していきます。

  • Root
    • UserViewer
      • PagingButton (ページングを促すボタン)
      • UserList (ユーザーを列挙するコンポーネント)

コンポーネント階層図

FetchUsersLazyQueryProviderを設置する

これにより、UserViewer以下でFetchUsersLazyQueryProviderに関連するHooksが利用可能となります。

import { FC } from 'react'
import { FetchUsersLazyQueryProvider } from 'path/to/FetchUsersLazyQueryProvider'
import { UserViewer } from 'path/to/UserViewer'

export const Root: FC = () => {
  return (
    <FetchUsersLazyQueryProvider>
      <UserViewer />
    </FetchUsersLazyQueryProvider>
  )
}

初回ロードが必要なコンポーネントで、useFetchUsersFetchFirstを利用する

useEffectでRootコンポーネントのマウント時にクエリを実行されるようにします。

import { FC, useEffect } from 'react'
import { useFetchUsersFetchFirst } from 'path/to/FetchUsersLazyQueryProvider'

export const UserViewer: FC = () => {
  const setShouldFetchFirst = useFetchUsersFetchFirst()

  useEffect(() => setShouldFetchFirst(true), [])

  return (
    <>
      <PagingButton />
      <UserList />
    </>
  )
}

ページングが必要なコンポーネントで、useFetchUsersFetchMoreを利用する

PagingButtonコンポーネント内で、ボタンクリック時にページングを実行を可能にします。

import { FC } from 'react'
import { useFetchUsersFetchMore } from 'path/to/FetchUsersLazyQueryProvider'

export const PagingButton: FC = () => {
  const setShouldFetchMore = useFetchUsersFetchMore()

  return <button onClick={() => setShouldFetchMore(true)}>Fetch More</button>
}

lazyQueryHookの戻り値を利用する

UserListコンポーネント内で、lazyQueryHookの戻り値を用いて、データの取得状況を表示します。

import { FC } from 'react'
import { useFetchUsersData, useFetchUsersLoading } from 'path/to/FetchUsersLazyQueryProvider'

export const UserList: FC = () => {
  const usersData = useFetchUsersData()
  const loading = useFetchUsersLoading()

  return (
    <div>
      {/* dataの利用イメージ */}
      {usersData?.users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
      {/* loadingの利用イメージ */}
      {loading && <div>Loading...</div>}
    </div>
  )
}

まとめ

以上が、Apollo Clientのクエリの実行タイミングを複数のコンポーネント間で制御する手法になります。
contextを用いることで、クエリの実行タイミングを制御するためのトリガーを、任意のコンポーネントで利用することができました。

リクエストの負荷を減らす場合、第一にはApollo Clientのキャッシュを利用するアプローチが挙げられます。
ただ、Reactのコンポーネントのライフサイクルやユーザーの操作に応じて、クエリの実行タイミングを制御したい、といったこともあるでしょう。
モーダルやタブ、データテーブルなど、複雑な表示制御を行うコンポーネントが混在する状況ではなおさらです。
そのような場合に、本稿で紹介したような手法が活用できそうです。


もっと、アルダグラムのエンジニア組織を知りたい人は、下記の情報をチェック!

アルダグラム Tech Blog

Discussion