1. shippokun

    fix: typo

    shippokun
Changes in body
Source | HTML | Preview

「辛かった」と書いていますが、実際にはまだ「辛い」です。
Apollo の導入を検討している方の参考になれば幸いです。

概要

  • React Native + TypeScript のプロジェクトで Apollo Client を使ってみた
  • すげー便利!って感じで最初は始まった
  • だんだん、 Apollo のキャッシュが効きすぎて、「画面更新されない」問題にぶち当たる
  • そのうち、 Apollo のキャッシュを無効化する作業に入る
  • Apollo 使ってる意味なくね、ってなる
  • MobXfetch ベースのライブラリ ky ベースで、全部書き換える(進行形)

Apollo Client とは

image.png

https://www.apollographql.com/

Apollo Client は、 Apollo という GraphQL 界の重鎮が作っているフレームワークのクライアントライブラリです。

Apollo Server や Apollo CLI などもありますが、 Apollo Client は React などの JavaScript ライブラリと組み合わせて使うライブラリになります。

Apollo Client の特徴

  • コンポーネントが マウントされた時に、データ取得が走る (ここ重要)
  • __typenameid によるキャッシュにより、無駄なデータの取得をしない
    • フィールドが追加された場合は、再度取得する
    • クエリ時に fetchPolicy: 'no-cache' などとすると、強制的に再取得する
  • ミドルウェア的なものをかませて、エラー時の処理などを追加できる
  • Redux や MobX などに代わる、 apollo-link-state というライブラリと組み合わせると、最強(になるはず)

具体的には、こんな感じです。

UserDetail.tsx
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 で苦しんだのは、共通ロジックです。

コンポーネント間で共有したいロジックがある場合(例: ユーザーの生年月日をパースして、年齢を出す)、コンポーネントにべた書きすると、ロジックが各コンポーネントに分散するので、微妙です。

そこで、下記のようなファイルを作成してまとめれば、いける、と判断しました。

entities/User.ts
export function getAge(user: User) {
  //
}

Apollo でシリアライゼーション層みたいなのができればいいんですけどね。結構調べたけど、無理っぽいですね。

結果的に、これは、何とかなりました。実際には、 Partial<User, 'birthday'> などとして、汎用性を高めています。

https://github.com/apollographql/apollo-tooling#apollo-clientcodegen-output

すげー便利

上記の書き方を採用すると、コンポーネントはちょっと厚くなりますが、全体的なコード量が減るので、便利になります。
特に Redux 等のライブラリで状態管理をするよりも、格段にコード量は減ります。

また、 apollo-codegen という神ツールを使うと、 GraphQL から TypeScript の型定義を作成できるので、工数がかなり減らせます。静的な GraphQL のチェックにもなる。 CI で回せばなお良い。
これは今でも使っています。

Apollo のキャッシュ に苦しみ始める

キャッシュ。これが難しい。

単純なユーザー情報の更新を考えます。

  • Show コンポーネントで、ユーザーが自身の情報を見る
  • Edit コンポーネントで、ユーザーが Update 系の行動をする (mutation
  • Show コンポーネントで、もう一度自身の情報を見る

これは比較的簡単です。 Edit コンポーネントで mutate する時に、ユーザー情報を全て fetch すれば良いからです。
ただ、これだけでも、

  • 取得フィールドを ShowEdit でほぼ完全に一致させる
  • mutation 側のクエリで fetchPolicy: 'no-cache'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.loadingfalse になる
    • そのためには notifyNetworkStatus: true オプションを指定する
    • でも指定しても false のままになる(ことがある?)
  • refetch 時に、関連コンポーネントの状態が更新されるので、画面の後ろ側に出ているコンポーネント側でエラーになったりする
    • デバッグがしんどい

など、マイナーだけど重要な仕様に悩まされました。
コンポーネントがエラーになったり、再取得時にローディングが出せない。
せっかく年齢を偽って、エロ動画が見れるようになったのに、15歳の少年はそれに気づかない可能性がある。

これでは運営も少年も、悶々とした日々を送ることになってしまいます。

対策としては、年齢を変更した時に、 Redux 等で別でステート管理し、そちらで GraphQL リクエストを発火させ、キャッシュを更新させる、という方法が思いつきます。
が、あまり直感的ではないコードになりそうです。

Apollo のキャッシュは優秀ですが、 React Native では致命傷を負いかねない、という結論になりました。

どうしたか

react-apollo を捨てて、 MobX に置き換えました。

Redux でも何でも良いんですが、 mapStateToProps 等の記述量と TypeScript との相性を考えた結果です。

MobX で記述すると、下記のメリットがありました。

  • キャッシュ削除の処理をストア側で一元管理できる
  • ストアに状態操作ロジックが集中するので、コンポーネントが薄くなる
  • MobX ではストアはただのオブジェクトなので、テストが書きやすい

また、未だに apollo-client というライブラリで GraphQL を取得していますが、これも ky というライブラリでやろうと思っています。

https://github.com/sindresorhus/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歳です。