「辛かった」と書いていますが、実際にはまだ「辛い」です。
Apollo の導入を検討している方の参考になれば幸いです。
[追記 2021年4月]
本記事を書いた時点(2019年9月)では、私の Apollo Client の理解が浅かったのが原因でした。コメントで仰って下さっている通り queryWrite
等のキャッシュをちゃんと設定すれば、大半のケースでは問題が無いと思います。
ただ、 Apollo Client で辛いと感じている方がいるのは事実で、特にキャッシュの部分はこちらの記事でも言及されていました。
個人的には、現在は URQL で additionalTypenames
をちゃんと使ってます。 Refetch を自動的に行ってくれるので最高です。
[/追記]
概要
- React Native + TypeScript のプロジェクトで Apollo Client を使ってみた
- すげー便利!って感じで最初は始まった
- だんだん、 Apollo のキャッシュが効きすぎて、「画面更新されない」問題にぶち当たる
- そのうち、 Apollo のキャッシュを無効化する作業に入る
- Apollo 使ってる意味なくね、ってなる
-
MobX
とfetch
ベースのライブラリky
ベースで、全部書き換える(進行形) - (2021年4月) URQL を愛用
Apollo Client とは
Apollo Client は、 Apollo という GraphQL 界の重鎮が作っているフレームワークのクライアントライブラリです。
Apollo Server や Apollo CLI などもありますが、 Apollo Client は React などの JavaScript ライブラリと組み合わせて使うライブラリになります。
Apollo Client の特徴
- コンポーネントが マウントされた時に、データ取得が走る (ここ重要)
-
__typename
とid
によるキャッシュにより、無駄なデータの取得をしない- フィールドが追加された場合は、再度取得する
- クエリ時に
fetchPolicy: 'no-cache'
などとすると、強制的に再取得する
- ミドルウェア的なものをかませて、エラー時の処理などを追加できる
- Redux や MobX などに代わる、
apollo-link-state
というライブラリと組み合わせると、最強(になるはず)
具体的には、こんな感じです。
import { graphql } from 'react-apollo'
import gql from 'grpahql-tag'
import { ActivityIndicator, View, Text } from 'react-native'
const GET_USER = gql`
query getUser {
user {
id
name
}
}
`
// 実際には、 $WithGraphQL<User> みたいにして DRY にする
interface WithUser {
data: {
user: {
id: number
name: string
birthday: string
}
}
}
export function UserDetail({ data }: WithUser) {
if (data.loading) {
return <ActivityIndicator />
}
return (
<View>
<Text>{data.user.id}</Text>
<Text>{data.user.name}</Text>
</View>
)
}
export default grpahql(GET_USER)(UserDetail)
辛くなっていく過程
最初
ブラウザの React で使ったことがあり、今回の React Native のプロジェクトでも使うことにしました。
ただ、ブラウザの React で苦しんだのは、共通ロジックです。
コンポーネント間で共有したいロジックがある場合(例: ユーザーの生年月日をパースして、年齢を出す)、コンポーネントにべた書きすると、ロジックが各コンポーネントに分散するので、微妙です。
そこで、下記のようなファイルを作成してまとめれば、いける、と判断しました。
export function getAge(user: User) {
//
}
Apollo でシリアライゼーション層みたいなのができればいいんですけどね。結構調べたけど、無理っぽいですね。
結果的に、これは、何とかなりました。実際には、 Partial<User, 'birthday'>
などとして、汎用性を高めています。
すげー便利
上記の書き方を採用すると、コンポーネントはちょっと厚くなりますが、全体的なコード量が減るので、便利になります。
特に Redux 等のライブラリで状態管理をするよりも、格段にコード量は減ります。
また、 apollo-codegen
という神ツールを使うと、 GraphQL から TypeScript の型定義を作成できるので、工数がかなり減らせます。静的な GraphQL のチェックにもなる。 CI で回せばなお良い。
これは今でも使っています。
Apollo のキャッシュ に苦しみ始める
キャッシュ。これが難しい。
単純なユーザー情報の更新を考えます。
-
Show
コンポーネントで、ユーザーが自身の情報を見る -
Edit
コンポーネントで、ユーザーが Update 系の行動をする (mutation
) -
Show
コンポーネントで、もう一度自身の情報を見る
これは比較的簡単です。 Edit
コンポーネントで mutate
する時に、ユーザー情報を全て fetch すれば良いからです。
ただ、これだけでも、
- 取得フィールドを
Show
とEdit
でほぼ完全に一致させる - mutation 側のクエリで
fetchPolicy: 'no-cache'
という注意点があります。(これエラー出ないから、気づかないんだよな・・・)
次に、もっと複雑な状態変化を考えます。ちょっと具体的です。
-
Show
コンポーネントで、ユーザーが自身の情報「と」閲覧可能な動画が表示される- この時、ユーザーは15歳で登録している、とする
-
Edit
コンポーネントで、ユーザーが生年月日を変えて、30歳になる - サーバーサイドで、ユーザーの別の状態が更新される
- 例: 生年月日を変えたら、エロ動画も表示できるようになる
-
Show
コンポーネントで、ユーザーが自身の情報「と」閲覧可能な動画が表示される- ここで、エロ動画を表示させたい!
※エロ動画はただの例です。
この時、動画を取得するクエリを 再度実行(refetch) しないといけないですが、 React Native + Apollo だとこれが難しい。
理由としては、同じ React でも、ブラウザとスマホアプリの違いとして、ブラウザでは「画面リロード」という最終手段があるのに対し、スマホアプリでは、アプリを落とさないといけない、というのがあります。
対策として、強制的にキャッシュを無効化し始めます。
graphql(GET_USER_AND_MOVIES, {
fetchPolicy: 'no-cache',
})(UserDetail)
一見これで解決しそうです。実際、ブラウザでは、画面変更により再度コンポーネントがマウントされるので、GraphQLが再度実行され、問題ありません。
が。アプリの場合、 componentDidMount
が、アプリでは Stack を重ねるだけなので発火しないことがあります。
React Navigation というライブラリでルーティングを管理していたのですが、最終的に、 下記のようなコードを随所に書き始めるようになりました。
componentDidMount() {
const { data, navigation } = this.props
navigation.addListner('willFocus', () => data.refetch())
}
ただし、こうしても、ナビゲーションの種類( TabNavigation
なのか、 StackNavigation
なのか、更にその親子関係)によって挙動が代わるので、それも意識しながらプログラミングする必要が出てきます。
また、他にも
-
refetch
を行った時はdata.loading
がfalse
になる- そのためには
notifyNetworkStatus: true
オプションを指定する - でも指定しても
false
のままになる(ことがある?)
- そのためには
-
refetch
時に、関連コンポーネントの状態が更新されるので、画面の後ろ側に出ているコンポーネント側でエラーになったりする- デバッグがしんどい
など、マイナーだけど重要な仕様に悩まされました。
コンポーネントがエラーになったり、再取得時にローディングが出せない。
せっかく年齢を偽って、エロ動画が見れるようになったのに、15歳の少年はそれに気づかない可能性がある。
これでは運営も少年も、悶々とした日々を送ることになってしまいます。
対策としては、年齢を変更した時に、 Redux 等で別でステート管理し、そちらで GraphQL リクエストを発火させ、キャッシュを更新させる、という方法が思いつきます。
が、あまり直感的ではないコードになりそうです。
Apollo のキャッシュは優秀ですが、 React Native では致命傷を負いかねない、という結論になりました。
どうしたか
react-apollo を捨てて、 MobX
に置き換えました。
Redux でも何でも良いんですが、 mapStateToProps
等の記述量と TypeScript との相性を考えた結果です。
MobX で記述すると、下記のメリットがありました。
- キャッシュ削除の処理をストア側で一元管理できる
- ストアに状態操作ロジックが集中するので、コンポーネントが薄くなる
- MobX ではストアはただのオブジェクトなので、テストが書きやすい
また、未だに apollo-client
というライブラリで GraphQL を取得していますが、これも ky
というライブラリでやろうと思っています。
これは Apollo から距離を置きたい気持ちがあります。
が、一旦、下記のような関数を使ってリクエストしています。
export const apolloClient = new ApolloClient({
link: ApolloLink.from(links),
cache,
})
export function query<T, V extends {} = {}>(query: any, variables?: V) {
return apolloClient.query<T, V>({
fetchPolicy: 'no-cache',
query,
variables,
})
}
export function mutate<T, V extends {} = {}>(mutation: any, variables?: V) {
return apolloClient.mutate<T, V>({
fetchPolicy: 'no-cache',
mutation,
variables,
})
}
考察
react-apollo はデータ・ドリブンなコンポーネントが作れて良いのですが、アプリ全体に関わるようなロジックは、 Apollo で管理すると荷が重いのではないか、と考えています。
もっと上手くキャッシュを扱う方法があるとは思いますが、現時点での私の能力では太刀打ち出来ないものでした。
現在も react-apollo は使っていますが、下記のようなユースケースに絞っています。
- アプリ内で変更が滅多に起こらない部分
- 例: カテゴリ一覧。素人、熟女、清楚、ロリ、など
- データがコンポーネント内に完全に閉じている部分
- 例: お気にいりのエロ動画。追加と削除しかなく、他の部分に影響を与えない
以上です。ちなみに私は26歳です。