こんにちは、主にフロントエンド領域を担当している林と申します。弊社ではWebフロントエンドの開発にReactを採用することが多いのですが、React + GraphQL + Apollo Clientの構成で新規開発をする機会がありました。これまでReact HookやReduxで行っていた状態管理の大部分をApollo Clientで実現しようとした際の挑戦や戸惑いなどを紹介します。
Apollo ClientはGraphQLを使用する際のJavaScriptのためのライブラリで、本記事で例示するReactでのプロジェクトだけではなくVueやAngular、さらには素のJavaScript(いわゆるVanilla JS)で開発されたプロジェクトに対しても導入することが可能です。
Apollo Clientは大きく分けて以下の役割を果たします。
単なるGraphQLのためのAPIクライアントではなく、アプリケーション内部の状態管理を行うための機能も複数備えています。(実際に公式でも状態管理ライブラリとして定義されています)
Apollo Clientには状態管理ための様々な機能がありますがここでは利用頻度が多かった機能を紹介します。
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を設計する時に考慮する正規化が既にされているので、レスポンスを受け取ったら即座に更新が行われます。
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クライアントとして捉えていたのですが、公式ドキュメント及びブログを熟読していくうちに上で紹介した状態管理の機能を多数備えていることがわかりました。
Reduxを中心に状態管理を進めていくことを検討していたのでここでやや面を食いました。まずApollo Clientの状態管理機能を利用しつつ、使い慣れたReduxを利用することができないかを検討したのですが以下の懸念点が上がりました。
また、Apollo Clientのみで状態管理を行うことも検討しましたがやはりいくつか懸念点が上がりました。
上記の懸念点を考慮しながら検討を進めていった結果、「状態管理の重複を避けるためになるべくApollo Clientで状態管理を行う」という方針で開発を進めていくことになりました。 重複を避けるのであればApollo Clientのcacheは使用せずにReduxのみで状態管理を行う方法もあったのですが、サーバーからのレスポンスが即座に正規化されて状態管理されるという部分に魅力を感じて上記の方針に至りました。また、Apollo Clientのみで状態管理を行うことの懸念点は妥協して受け入れなければなりませんでしたがいくつか設計方針を設けて対策を試みました。
懸念点の対策も兼ねて以下の設計方針を設けました。
状態管理の多くをApollo Clientで行うように開発して良かったこともあれば辛かったことも多々ありました。Apollo Clientの柔軟さに助けられた反面、懸念していた点に苦しんだ場面がありました。
Reduxを使用するときは、storeの整合性を常に取るため、またデータ自体のネストを浅くするために自前で正規化する処理を記述していたのですがApollo Clientには既にその機能が備わっていたためこの部分の工数を抑えることができました。また、Reduxを使用する際にstoreの正規化をしっかりと行うことの意義を再確認につながりました。
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が備わり使いやすくなった印象です。
Queryを叩いてデータ取得を行っているコンポーネントが再レンダリングされた時にサーバーサイドからデータを取得せずにcacheを参照してほしい場面がいくつもあったのですが、fetch policyの仕組みにより簡単に挙動を変えることができました。
import {useFetchUsers} from '../../types/generated/graphql'
...
useFetchUsers({
fetchPolicy: 'cache-first', // キャッシュを優先的に見に行く
...
})
useFethcUsers({
fetchPolicy: 'cache-only', // キャッシュのみを見に行く
...
})
useFetchUsers({
fetchPolicy: 'network-only', // 常にサーバーサイドからの取得を行う
...
})
上記に記述した他にもfetch policyは存在するのですが、これらの機能を利用することによりサーバーサイドへのリクエストの回数を減らすことができました。
(これは状態管理とはあまり関係ないのですが) ポーリングして数秒間隔で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を利用する人に少しでも参考になれば幸いです。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!