📈

RecoilでGraphQLリクエストを扱う

2023/02/06に公開2

こんにちは。SALESCORE株式会社のCTOの成澤です。
最近、弊社のフロントエンドアプリケーションでGraphQLリクエストをRecoilに乗せる運用を始めたので、こちらについてお話しします。

参考文献

記事の内容はuhyoさんの記事とかなり近しいので、ぜひこちらもお読みください。

https://zenn.dev/babel/articles/recoil-for-babel

この記事では、urqlではなくApollo Clientを使った設計と、それに関わる実践的なテクニックを紹介します。

Recoilにロジックを載せて、Reactからロジックを剥がす

SALESCOREの最近の開発では、Recoilにロジックを書いてコンポーネントからはフックを呼び出すだけ、コンポーネントに極力ロジックを書かない、という開発スタイルを取っています。
これにより「状態」と「ビュー」を明確に分離することができ、見通しよく開発が行えています。

フロントエンド開発の難しさの一端は、本来はただのインターフェースを構築するためのUIライブラリ(React)に各種のロジックを書きがちで、責務の分離ができていないことによると考えます。バックエンドであればMVCのような枯れた設計スタイルがありますが、フロントエンドだとVに全部詰め込むような形になりがちですね。

この問題意識はフロントエンドアプリケーションが複雑化してきた一昔前から常に叫ばれていることであり、今更言うほどのことではありません。しかし、それに対する今までの各種の解決方法、例えばReduxなどは正直あまり良いと思えるものがありませんでした(個人の感想)。

それに比べてRecoilは、より洗練されたグローバルなステート管理ライブラリというだけでなく、データフローグラフという概念を全面に押し出し、簡潔で開発体験の良い状態管理を提供していると感じます。

特にatom(状態)とselector(状態から派生して計算させた値)が設計上明確に分かれているのが良いと感じます。バックエンドエンジニアとしては、atomを定義するのはバックエンドでDBにテーブルを定義するような気持ちになります。
そこからselectorを積み上げていってデータフローを構築し、最後にReactからフックを呼び出すだけという開発体験はかなり気に入っています。

最近だとuhyoさん中心にRecoilの勉強会も開かれており、国内でも盛り上がりを見せていますね。
https://speakerdeck.com/uhyo/sutetoguan-li-wochao-erurecoilyun-yong-nokao-efang

RecoilにGraphQLリクエストを載せる

さて、ロジックをRecoilに寄せていこうとしたとき、APIリクエストがRecoilに乗っていないと、以下のように「別途fetchしてきたデータをuseEffectを使ってrecoilにセットし直す」のような作業が発生します。

// NG
const PostsComponent: React.FC = () => {
  // APIリクエスト
  const posts = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const data = ... // GraphQLリクエスト
      return data;
    },
  });
  // recoilに詰め替える(面倒)
  const setPosts = useSetRecoilState(postsAtom)
  useEffect(() => {
    setPosts(posts)
  }, [posts])

  // Recoil側でリクエスト結果を絞り込みして表示
  const filteredPosts = useRecoilValue(filterdPostsSelector)
  return <div>
    {/* postsの表示(サンプル) */}
    {filteredPosts.map(x => x.name).join(', ')}
  </div>
}

こういうことがあると「面倒臭いからRecoilで書かなくていいか…」という力学が発生し、ロジックが分散しがちです。上記だと、多分コンポーネントに絞り込みのロジックをベタ書きしてしまうことになるでしょう。

できれば以下のように書けると理想的ですね。

// Good
const PostsComponent: React.FC = () => {
  // recoilでAPIリクエスト
  const filteredPosts = useRecoilValue(filterdPostsSelector)
  return <div>
    {filteredPosts.map(x => x.name).join(', ')}
  </div>
}

RecoilのSelectorは非同期処理も扱うことができ、上記のようなSelectorも実装することができます。
Apollo Clientを使ってGraphQLリクエストを行うasyc selectorを書いてみましょう。

const postsSelectorFamily = selectorFamily({
  key: `fetchPostsSelectorFamily`,
  get: (options: Omit<QueryOptions<FetchPostsQueryVariables>, 'query'>) => async ({ get }) => {
    const client = get(apolloClientAtom) // atomに詰めておいたapollo clientを取得
    const result = await client.query<FetchPostsQueryResult['data']>({
      ...options,
      query: FetchPostsDocument, // typescript-react-apolloで生成したドキュメント・型を利用する
    })
    if (result.data === undefined || result.error !== undefined) {
      throw new Error(`エラー`)
    }
    return result.data
  },
})

const filteredPostsSelector = selector({
  key: `filteredPostsSelector`,
  get: () => ({ get }) => {
    const { posts } = get(postsSelectorFamily)
    return posts.filter(post => ...) // 絞り込みロジック
  },
})

これだけで基本的な処理は終わりです。Recoil側がSuspense対応してくれるので、特にその辺を意識せずに処理を書くことができて良いですね。

実践編

Selectorの自動生成

さて、普段GraphQLクライアントを扱うときは、GraphQL Code Generatorなどを使って自動生成された値を使うのが普通でしょう。同じようにRecoilのSelectorも自動生成したいところです。
Recoil公式ではrecoil-relayを使った方法が紹介されており、relay推しのように見えます。

https://recoiljs.org/docs/recoil-relay/introduction/

GraphQLクライアントにrelayを使うのも悪くないのですが、以下の理由で弊社ではApollo Clientを使い続けることにしました。

  • 既存機能がApollo Clientを使っているから(主な理由)
  • Relayが発展途上に見えたから
    • ドキュメントが分かりづらかった、バージョンアップのためかサンプルコードが動かなかった
    • 対して、Apollo系スタックはドキュメントが非常に読みやすく、信頼感がある
  • Apollo Clientが好きだから(Linkの仕組みが好き)

Code Generatorはカスタムプラグインを書くことで、自由にコードを自動生成することができます。@graphql-codegen/typescript-react-apolloを一緒に使うことを前提とすれば、今回やることはdocumentsを受け取って上記のサンプルコード相当の文字列を生成するだけです。大して難しくもないので、これを使って自動生成してみましょう。

https://the-guild.dev/graphql/codegen/docs/custom-codegen/plugin-structure

丁寧に解説すると長くなるので、雰囲気コードを載せておきます。

雰囲気コード
  import { PluginFunction } from '@graphql-codegen/plugin-helpers'
  import { Kind } from 'graphql'
  
  type MyPluginConfig = {}
  export const plugin: PluginFunction<Partial<MyPluginConfig>> = (schema, documents, config) => {
    const operations = documents
      .flatMap((x) => x.document?.definitions.map((x) => (x.kind === Kind.OPERATION_DEFINITION ? x : undefined)))
    const queries = operations.filter((x) => x.operation === 'query')
    const queryNames = queries.map((x) => x.name?.value)
    const selectors = queryNames.map((query) => generateSelector(query))
    // ...
    return [
      ...selectors,
    ].join('\n\n')
  }
  
  function generateSelector(operationName: string) {
    return `const ${operationName}SelectorFamily = selectorFamily({
    key: '${operationName}SelectorFamily',
    get: (options: Omit<QueryOptions<${generateQueryVariablesTypeName(operationName)}>, 'query'>) => async ({ get }) => {
      const client = get(apolloClientAtom)
      const result = await client.query<${generateQueryResultTypeName(operationName)}['data']>({
        ...options,
        query: ${generateDocumentTypeName(operationName)},
      })
      if (result.error !== undefined) {
        throw new Error(result.error.message)
      }
      if (result.data === undefined) {
        throw new Error('データの取得に失敗しました')
      }
      return result.data
    },
  })`
  }

実際の使用方法

実際のプロダクションコードでは、もう少し色々と手を加えてコードを自動生成し、以下のようにフックを呼び出しています。

import { api } from '../../graphql/generated'

// コンポーネントから直接呼び出す時
const PostsComponent: React.FC = () => {
  const posts = api.fetchPosts({})
  const updatePost = api.useUpdatePost()

  return <div>
    {/* postsの表示(サンプル) */}
    {posts.map(x => x.title).join(', ')}
    {/* postsの更新(サンプル) */}
    <button onClick={() => { await updatePost({ title: `foo` })}}>
      保存
    </button>
  </div>
}

// selectorで使う時
const filteredPostsSelector = selector({
  key: `filteredPostsSelector`,
  get: () => ({ get }) => {
    const posts = get(api.fetchPostsSelectorFamily({})).posts
    return posts.filter(post => ...) // 絞り込みロジック
  },
})

api オブジェクト経由で各種の関数を呼び出す形にしていますが、バックエンドで db (Prismaインスタンス)を呼び出す開発体験に近くて気に入っています。const posts = api.fetchPosts({}) と書けると、もはやバックエンドの書き心地と変わりませんね。

fetchPosts() でAsync Selectorを使ったAPIリクエストで、実態はuseRecoilValue(fetchPostsSelector) 相当となります。
これはフックなので useFetchPosts() という命名が慣習的に正しいですが、 use が入ってフックであることを意識させるよりも、バックエンドと同じような書き心地にするのを優先しています。初見でコードを見るときは不親切なので、賛否が分かれそうです。

キャッシュのクリア

APIから再取得したい時はどうすればいいでしょうか。selectorは依存しているatomに変更がない限り、再実行されません。
この解決方法は公式に書いてあります。

https://recoiljs.org/docs/guides/asynchronous-data-queries#query-refresh

このうち、弊社では再計算させるためのatomを用意する方法を選択しました。

const apiRequestIdAtom = atomFamily<number, { operation: string; options?: SerializableParam }>({
  key: 'apiRequestIdAtom',
  default: 0,
})

const postsSelectorFamily = selectorFamily({
  key: `fetchPostsSelectorFamily`,
  get: (options: Omit<QueryOptions<FetchPostsQueryVariables>, 'query'>) => async ({ get }) => {
    // optionsに依存するキャッシュ/依存しないキャッシュの2種類用意しておく
    get(apiRequestIdAtom({ operation: 'fetchPostsQuery' }))
    get(apiRequestIdAtom({ operation: 'fetchPostsQuery', options }))
    // 取得処理……
  },
})

// invalidateしたければ以下を実行
set(apiRequestIdAtom({ operation: 'fetchPostsQuery' }), x => x + 1)

Recoilがなければ、本来はApollo Clientが適切にキャッシュクリアを行なってくれるため、その辺の開発体験は悪くなっています。
例えば updatePostMutation を実行すれば、Apollo Clientが勝手に fetchPostsQuery のレスポンスも再計算してくれるという神機能があるのですが、selectorを挟むとこれが行われません。

弊社では一旦この辺の機能劣化は受け入れる選択をしましたが、もしここが本質的に問題になるのであれば、依存関係に応じて適切にselectorを再計算するような仕組みを自前でいれようと考えています。

Apollo Clientは単なるGraphQLライブラリではなく状態管理ライブラリなので、この面ではRecoilとは組み合わせが悪いですね。新規で開発を始める際はurqlから検討をすると良いかもしれません。

2回目以降のリクエスト中のローディングについて

Recoilはステートの一貫性という性質を持っています。

例えば「検索クエリ」「検索結果」をそれぞれatom / async selectorで実装しているとき、「検索クエリが更新されたが、検索結果は前の結果のまま」という状態はRecoilにおいてはありえません。
詳しい説明は以下をご覧ください。

https://zenn.dev/uhyo/articles/recoil-vs-rxjs#実用観点で見るrecoilとrxjsの共通点・相違点

このような一貫性は基本的に望ましい挙動なのですが、ユーザー体験にこだわり始めると必ずしも良いとはいえない挙動でもあります。
RecoilのAsync Selectorの挙動だと、2回目の検索中はselectorがPromiseをthrowするため、1回目の検索結果は取得できなくなりますが、例えば画面のチラつきを防ぐために「1回目の結果は表示させつつローディング表示を行う」のような挙動が欲しくなるUIもあるかと思います。

Apollo Clientを使っていると自然と上記のような体験を達成できますが、Recoilでこのような挙動を達成するには、useEffectを使ってキャッシュを保存しておくしかなさそうです。弊社では用途に応じて以下のようなフックを自動生成する形としました。

const useFetchPosts = (
  variables: FetchPostsQueryVariables,
  options?: Omit<QueryOptions<FetchPostsQueryVariables>, 'query' | 'variables'>,
  hooksOption?: HooksOption,
) => {
  const state = useRecoilValueLoadable(fetchPostsSelectorFamily({ ...options, variables }))
  const [cache, setCache] = useRecoilState(fetchPostsCacheAtom({ ...options, variables }))

  useEffect(() => {
    if (
      state.state === 'hasValue' &&
      state.contents !== undefined &&
      (hooksOption?.useCacheWhileLoading ?? defaultHooksOption.useCacheWhileLoading)
    ) {
      setCache(state.contents)
    }
  }, [state.state, state.contents])

  switch (state.state) {
    case 'hasValue': {
      return state.contents
    }
    case 'loading': {
      if (cache !== undefined) {
        return cache
      }
      throw state.contents
    }
    case 'hasError':
      throw state.contents
  }
}

// loadingを検知するフックは別途用意する(省略)

まとめ

この記事では、Apollo Clientを使ったGraphQLリクエストをRecoilに載せる方法について説明しました。
記事で書いた通り、弊社のコードでは複雑なロジックはRecoilに載せる方針にしている一方で、簡単で薄いロジックについては引き続きコンポーネント側に書いています。アーキテクチャの話全般に言えることですが、基本的な開発ポリシーは持ちつつも、最終的には「どちらに書くのがベターか?」を考えて柔軟な対応をすると良いと考えます。
記事中の例は説明のため簡単なロジックで説明していますがご了承ください。

Recoilを使い始めて、フロントエンドでの悩み事が減ったなと本当に感じているので、ぜひみなさん使ってみてください。

SALESCOREテックブログ

Discussion

CollelisCollelis

こんにちは!
上記コードを参考にさせていただきました。ありがとうございます。
そこで一点、質問がございます。
recoil内でAPI取得、view側ではuseRecoilValueで取得しているのですが、
Warningで下記が出ます。
言われてみれば確かになんですが、Narisawaさんはこちらはどのように処理されてますか?
それともdevの間のみのものなので、特になにもされてませんでしょうか?

Warning: Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously later calls tries to update the component. Move this work to useEffect instead.
Katsuma NarisawaKatsuma Narisawa

コメントありがとうございます!
そちらのエラーは手元で発生しておらず、ちょっと分からないですね…。お役に立てずすみません!