「3種類」で管理するReactのState戦略

2021/12/31に公開

こんにちは、よしこです。

この記事は 2020年に立ち上げたWebフロントエンド構成の振り返り の「Stateのアーキテクチャ」項の詳細記事です。単体でも読めますが、よければ元記事もあわせてどうぞ!


この記事では、今わたしが開発・運用しているアプリケーションのState戦略についてご紹介していきます。

全体像

アプリケーションに存在する状態(State)を以下の3種類に分類し、それぞれのやり方で管理しています。

  1. サーバーデータのキャッシュ
  2. Global State
  3. Local State

1. サーバーデータのキャッシュ

「SPAで管理する必要のあるGlobal Stateって、そのうちほとんどがサーバーデータのキャッシュだよね。それを取り除けたら、管理する必要のあるGlobal Stateってすごく小さくなるんじゃない?」という主張を私が認識しはじめたのが2020年の初旬でした。おそらくSWRやReact Queryが日本で話題になり始めたのがその頃で、それを調べて逆引きする形で課題や思想を知ったんだったかな?とにかく、言われてみればその通りという感じで、とても共感できました。
キャッシュ機構を持つFetch Clientの存在は、以前からGraphQLの世界では馴染み深いものだったと思います。わたしはGraphQLの経験はかなり浅いので、それを試すことができたのは今回のアプリケーションからとなりました。

SWRを使ったサーバーデータのキャッシュ

SWR そのものについては他にたくさん良い記事があるのでこの記事では解説はしません。それらをアプリケーションでどのように使っているのかだけ簡単に紹介します。

たとえば、ユーザー一覧・詳細取得でいうとこんな感じです。
Usecase層に以下を定義し、Componentからこれらを利用しています。

// ユーザー一覧取得
type UserGetListResponse = { users: User[] }
export const useUserList = () => {
  const repository = useUserRepository()
  
  return useSWR<UserGetListResponse>(
    userCacheKeyGenerator.generateListKey(), // key
    () => repository.getList(),              // fetcher
  )
}

// 詳細取得
type UserGetItemResponse = { user: User }
export const useUserItem = (query: { id: User['id'] }) => {
  const repository = useUserRepository()

  return useSWR<UserGetItemResponse>(
    userCacheKeyGenerator.generateItemKey(query), // key
    () => repository.getItem(query),              // fetcher
  )
}

これで、Component側で useUserList() を呼び出すと

  • もしSWR内のキャッシュに既にデータが存在すれば、まずはそれがComponentに返る
  • fetcherによるサーバーからのデータ取得
  • 取得したデータがSWR内のキャッシュに書き込まれる
  • 取得したデータがComponentに返る

というふうに動き、サーバーデータの管理をGlobal StateではなくSWRのキャッシュ機構に任せることができます。
加えて、たとえばユーザー削除などリソースの操作をしたことで一覧のデータに変化があるはずなのでキャッシュを無効にしたい、という場合には同じKeyでmutateを呼び出すことで実現できます。上記のコードに対応するコードだと mutate(userCacheKeyGenerator.generateListKey()) のような感じですね。なので、それをユーザーを操作する各カスタムフックの中に入れています。

サンプルコードに出てきたUsecase, Repository, CacheKeyGeneratorなどの解説はまた別途、元記事の「Applicationのアーキテクチャ」の深堀り記事で紹介できればと思います。

2. Global State

Global Stateから1の手法によってサーバーデータのキャッシュを取り除いたあとにも、クライアントに必要ないくつかのGlobal Stateが残ります。
今のプロジェクトでは、「ページをまたいで保持し続ける必要のあるState」をGlobal Stateとして定義しています。
例えば認証情報や、ページをまたいで表示する必要のあるToast、継続してユーザーに知らせ続ける必要のあるバックグラウンド処理の進行状況などです。

これらのGlobal Stateの管理にはRecoilを使っています。

要件的にはReactに元々入っているContextでも実現できないことはないのですが、それを使わなかった理由は、APIの好みによる部分が大きいです。

  • Stateを分割したいときにJSXとしてProviderを差し込む必要がある
  • State更新の手段が提供されていないので、Setterも自分で用意して値と同列に生やす必要がある
  • createContextとProviderのvalueの2箇所で初期値を指定しないといけない

あたりの使い勝手があまりしっくりきておらず、さらに今回はSingle State + Selector という設計ではなくStateの用途単位で個別にStateを作っていく設計にしたいと思っていたので、使い勝手のよい形でStateを柔軟に増減させられることが理想でした。
そこで、ちょうどその頃公開されて間もなかったRecoilのAPIがとても理想的だったため、採用を決めて開発を進めてきました。

Recoilの解説についてはuhyoさんの Facebook製の新しいステート管理ライブラリ「Recoil」を最速で理解する がおすすめです。

Recoilを使ったGlobal Stateの管理

以下のルールで運用しています。

  • /src/globalStates 以下に 1Stateあたり1ファイルを作成する
  • ファイル名をkeyにする(衝突しないことが担保できる)
  • StateそのものやsetStateを直接露出させず、ふたつのカスタムフックのみを露出させる
    • State の Read hook は useXxxxState として export
    • State の Write hook は useXxxxMutators として export
      • 中に setState を wrap した write methods を詰める。setState は直接露出させない
  • useXxxxState は任意のComponent/Usecaseから利用可能
  • useXxxxMutators は任意のUsecaseから利用可能

たとえば、夜ご飯のメニューを保持するGlobal Stateでサンプルコードを明示すると、以下のような感じです。

todaysDinnerState.ts
type DinnerType = 'Beef' | 'Chicken' | null
type TodaysDinnerState = {
  dinnerType: DinnerType
}

const todaysDinnerRecoilState = atom<TodaysDinnerState>({
  key: 'todaysDinnerState',
  default: { dinnerType: null },
})

export const useTodaysDinnerState = () => {
  return useRecoilValue(todaysDinnerRecoilState)
}

export const useTodaysDinnerMutators = () => {
  const setState = useSetRecoilState(todaysDinnerRecoilState)

  const setDinner = React.useCallback(
    (dinnerType: DinnerType) => setState({ dinnerType }),
    [setState],
  )

  return { setDinner }
}

RecoilのAPIを直接露出させずカスタムフックだけを露出させることでライブラリの知識を隠蔽できますし、Mutatorsではニーズごとの関数をexportすることでプロパティが複数ある場合でも期待する組み合わせの更新のみに制限することができます。

3. Local State

ページをまたいで保持する必要のないStateは単純に各Component内でuseStateを使って管理しています。
単純である代わりに、外側から操作できない状態が生まれてしまうためにstorybookのカバレッジが落ちてしまうという問題もあって、ここは少し悩んでいるところでもありました。

これを解決するにはComponentをContainer/Presenterで分割していくしかないのかなと思っていたのですが、最近出たStorybookのInteractive Stories機能がまさにこの問題に対する違った形での解決策になっていました。ComponentのStoryのレンダリング結果に対する任意の操作を記述できるというものです。
既に一部のComponentではこのInteractive Stories機能を活用してみており、既存のreg-suit + storycapのCI構成のままでばっちり操作後のスクリーンショットがとれました!
Componentに閉じたStateはそのほとんどがUI操作によって変更するためのものであるため、Interactive Storiesをマメに書いてVisual Regression Testのカバレッジを向上させていくと、それがそのままComponentのテストにもなるため、よりよい解決策になりそうだと期待しています。


以上!

深堀り記事を連載しながら紹介している今回のアプリケーション構成の中でも、State設計がアプリケーションに与えた影響は特に大きく、今回のStateの構成を取ることで今までは手で書いていたコードをだいぶ削れた実感があります。

元記事では他にも様々な項目の構成紹介をしています。よければあわせて読んでみてください!

https://zenn.dev/yoshiko/articles/32371c83e68cbe

Discussion