MoTLab -GO Inc. Engineering Blog-MoTLab -GO Inc. Engineering Blog-

ReactにApollo Clientを導入した時の状態管理について

FrontEnd
November 16, 2021

こんにちは、主にフロントエンド領域を担当している林と申します。弊社ではWebフロントエンドの開発にReactを採用することが多いのですが、React + GraphQL + Apollo Clientの構成で新規開発をする機会がありました。これまでReact HookやReduxで行っていた状態管理の大部分をApollo Clientで実現しようとした際の挑戦や戸惑いなどを紹介します。


Apollo Clientとは

Apollo ClientはGraphQLを使用する際のJavaScriptのためのライブラリで、本記事で例示するReactでのプロジェクトだけではなくVueやAngular、さらには素のJavaScript(いわゆるVanilla JS)で開発されたプロジェクトに対しても導入することが可能です。

Apollo Clientは大きく分けて以下の役割を果たします。

  • APIクライアント
  • キャッシュ等を利用した状態管理 + 状態管理のためのhookを提供

単なるGraphQLのためのAPIクライアントではなく、アプリケーション内部の状態管理を行うための機能も複数備えています。(実際に公式でも状態管理ライブラリとして定義されています)

Apollo Clientでの状態管理

Apollo Clientには状態管理ための様々な機能がありますがここでは利用頻度が多かった機能を紹介します。

Apollo Client内部のInMemoryCache

Apollo ClientにはGraphQLでサーバーサイドと通信した際のクエリやレスポンスを内部のcacheに保持する仕組みがあります。

cacheの仕組みを使用する場合はApollo Clientを初期化する際にInMemoryCacheを指定する必要があります。

import { ApolloClient, InMemoryCache } from '@apollo/client'
import possibleTypes from './possibleTypes.json'
import { typePolicies } from './typePolicies'

export const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies,
    possibleTypes: possibleTypes.possibleTypes
  }),
  connectToDevTools: process.env.NODE_ENV === 'development'
})

cacheの挙動としては、例えば以下のようなレスポンスが返ってきた場合は

data: {
	user: {
		id: "abcdefghijklmn"
		name: "田中太郎"
		address: "xxx市"
		__typename: "User"
	}
}

次のように型名とidをコロンで結合した文字列がキーになり、そのキーに対して中身が格納されます。

"User:abcdefghijklmn": {
	id: "abcdefghijklmn"
	name: "田中太郎"
	address: "xxx市"
	__typename: "User"
}

つまり、レスポンスをcacheに格納する場合はidを取得するようなクエリを書く必要があります。(Apollo Clientを初期化する際にこの辺りの設定を変更することもできます)

上記のようなcacheの状態で以下のようなレスポンスを受け取った場合は

data: {
	id: "abcdefghijklmn"
	name: "hoge太郎"
	address: "yyy市"
	__typename: "User"
}

次のようにcacheの中身が書き換えられます。

"User:abcdefghijklmn": {
	id: "abcdefghijklmn"
	name: "hoge太郎"
	address: "yyy市"
	__typename: "User"
}

このようにReduxでstoreを設計する時に考慮する正規化が既にされているので、レスポンスを受け取ったら即座に更新が行われます。

reactive variables

Apollo Clientのバージョン3で追加されたreactive variablesはアプリケーション内のグルーバル変数として使用することができます。cacheの外側で状態管理を行う時によく利用します。

使い方はまず変数を定義し、

import { makeVar } from '@apollo/client'
import { User } from './type'

export const selectedUserVar = makeVar<User>({
	id: "123456789"
	name: "fuga太郎"
	address: "zzz市"
})

参照時または更新時に次のように呼び出すだけです。

import { useReactiveVar } from '@apollo/client'
import { selectedUserVar } from './reactiveVariables'

// 参照時
const user = useReactiveVar(selectedUserVar)

// 更新時
selectedUserVar({
	id: "8104314041"
	name: "〇〇太郎"
	address: "vvv市"
})

Reduxを使用する時にセットアップする大規模なボイラープレートなどは必要ありません。

状態管理の技術選定

Apollo Client導入前は単にGraphQLのためのAPIクライアントとして捉えていたのですが、公式ドキュメント及びブログを熟読していくうちに上で紹介した状態管理の機能を多数備えていることがわかりました。

Apollo ClientとReduxを併用することの懸念点

Reduxを中心に状態管理を進めていくことを検討していたのでここでやや面を食いました。まずApollo Clientの状態管理機能を利用しつつ、使い慣れたReduxを利用することができないかを検討したのですが以下の懸念点が上がりました。

  • Apollo ClientのcacheとReduxのstoreで同じようなデータを保持することになってしまうのでSingle Source of Truthの原理に反する
  • cacheとstoreでデータの不整合が発生した場合にどちらのデータが正しいのか判断しづらい
  • cacheとstoreの操作を両方しないといけないのでコード量が非常に多くなる

Apollo Clientのみで状態管理を行うことの懸念点

また、Apollo Clientのみで状態管理を行うことも検討しましたがやはりいくつか懸念点が上がりました。

  • cacheの読み書きがReduxの操作と比べるとやや面倒
  • Reduxのように更新フローにルールがないので、秩序なくデータを変更できてしまう
  • Apollo Clientの社内での実績がないのでスケジュール的に何か起こった時に不安がある(あまりスケジュールに余裕がない状態だった、、、)

選定結果

上記の懸念点を考慮しながら検討を進めていった結果、「状態管理の重複を避けるためになるべくApollo Clientで状態管理を行う」という方針で開発を進めていくことになりました。 重複を避けるのであればApollo Clientのcacheは使用せずにReduxのみで状態管理を行う方法もあったのですが、サーバーからのレスポンスが即座に正規化されて状態管理されるという部分に魅力を感じて上記の方針に至りました。また、Apollo Clientのみで状態管理を行うことの懸念点は妥協して受け入れなければなりませんでしたがいくつか設計方針を設けて対策を試みました。

設計方針

懸念点の対策も兼ねて以下の設計方針を設けました。

  • readQueryやreadFragmentよりも fetchPolicyを利用してcacheからデータを読みにいくことを優先する
  • 秩序なくグローバルにアクセスできてしまうreactive variablesの使用は最小限に留めuseStateやuseContextなどのhookを使用して状態管理のスコープを狭くする
  • Apollo Clientの機能でハマって身動きが取れなくなった時はhookや部分的にreduxを導入して解決を目指す(第二の実装方法も頭に入れておく) 上記の方針を踏まえて構成をまとめると以下の図ようになります。
An image from Notion

開発を終えてみて

状態管理の多くをApollo Clientで行うように開発して良かったこともあれば辛かったことも多々ありました。Apollo Clientの柔軟さに助けられた反面、懸念していた点に苦しんだ場面がありました。

良かったこと

  • レスポンスとして取得したデータが即座に正規化されるのでReduxを使用するときのようにstoreの細かい設計が不要だった

Reduxを使用するときは、storeの整合性を常に取るため、またデータ自体のネストを浅くするために自前で正規化する処理を記述していたのですがApollo Clientには既にその機能が備わっていたためこの部分の工数を抑えることができました。また、Reduxを使用する際にstoreの正規化をしっかりと行うことの意義を再確認につながりました。

  • Apollo Clientが提供しているhookにより状態管理を柔軟に行うことができた

react-reduxパッケージからもuseSelectorやuseDispatchなどのhookが提供されていますが、Apollo Clientからも便利なhookが色々と提供されています。例えば、cacheから全てのデータを取り除きたい場合は以下のように提供されているhookで簡単に処理を記述することができました。

import { useApolloClient } from '@apollo/client'

...
// コンポーネントツリー内のApollo Clientを取得
const apolloClient = useApolloClient()
...
// cacheの中身を消去
await apolloClient.clearStore()

以前のApollo Clientはhookベースではなくcomponentベースで利用する形式だったのでそのイメージが強く残っていたのですが、Reactのバージョンアップと共に便利なhookが備わり使いやすくなった印象です。

  • cache or サーバーサイドからの取得を簡単に切り替えることができた

Queryを叩いてデータ取得を行っているコンポーネントが再レンダリングされた時にサーバーサイドからデータを取得せずにcacheを参照してほしい場面がいくつもあったのですが、fetch policyの仕組みにより簡単に挙動を変えることができました。

import {useFetchUsers} from '../../types/generated/graphql'
...
useFetchUsers({
 fetchPolicy: 'cache-first', // キャッシュを優先的に見に行く
 ...
})

useFethcUsers({
 fetchPolicy: 'cache-only', // キャッシュのみを見に行く
 ...
})

useFetchUsers({
 fetchPolicy: 'network-only', // 常にサーバーサイドからの取得を行う
  ...
})

上記に記述した他にもfetch policyは存在するのですが、これらの機能を利用することによりサーバーサイドへのリクエストの回数を減らすことができました。

辛かったこと

  • 懸念点の対策のために設計方針を立てたが機能として強制されているわけではないので止むを得ず守られなくなってしまいがち

  • reactive variablesは気軽に使えて便利だがReduxのようなルールがないので無法地帯化しやすかった

  • 状態管理以外の機能でハマることが多かった

(これは状態管理とはあまり関係ないのですが) ポーリングして数秒間隔でQueryを叩く場面がありました。途中でQueryに渡すパラメーターが変わっても最初に渡したパラメーターでポーリングし続けてしまう点にハマってしまいかなり時間を消費してしまいました。

import React, { useState, useEffect } from 'react'
import {useFetchShop} from '../../types/generated/graphql'
...
const [param, setParam] = useState<string>('abc')
const { data } = useFetchShop({
 variables: { param },
 pollInterval: 5000
})

const handleClick = () => {
 setParam('123') // これを実行してparamが変更されても
}                // useFetchShopのvariablesは'abc'のままポーリングし続ける

...

結局、以下のように一度ポーリングを止めてから再度ポーリングする処理を行うことになりました。

...
import {useFetchShopLazyQuery} from '../../types/generated/graphql'
...
const POLL_INTERVAL = 5000
const [param, setParam] = useState<string>('abc')
const { startPolling, stopPolling } = useFetchShopLazyQuery({
 variables: { param },
})

const handleClick = () => {
 setParam('123')
}

useEffect(() => {
  startPolling(POLL_INTERVAL)
}, [])

useEffect(() => {
  stopPolling()
  startPolling(POLL_INTERVAL)
}, [param])
...

状態管理の機能だけに気を取られていたのでこのような箇所で所々つまづいてしまいました。この他にも時間を取られた箇所はあったのですが、Apollo Clientのご利用の際は一通り仕様を眺めることおすすめします。

最後に

いかがでしたでしょうか? 初めての経験で戸惑うことも多かったのですが、Apollo Clientの状態管理の良さや辛さを知ることによりReduxの良さを改めて認識する機会になったので結果として挑戦して良かったと思っています。本記事がこれからApollo Clientを利用する人に少しでも参考になれば幸いです。

We're Hiring!

📢
Mobility Technologies ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @mot_techtalk のフォローもよろしくお願いします!