はじめに
こんにちは! テクノロジー戦略本部24年新卒の高橋です。
2023年の10月から内定者インターンを経験し、現在は開発3部CRMチームでフロントエンド(以後、FE)エンジニアとして働いております。
チーム内でFEの状態管理ライブラリを選定する機会があり、調査していく中で得た知見を共有したく、執筆に至りました。
少しでも状態管理ライブラリの選定に困っているFEエンジニアの参考になればと考えています。
概要
私たちのプロダクトは、開発当初から状態管理にReactのAPIであるContext APIを利用していました。
ですが、プロダクトの成長に従い、課題感を感じていました。
そこで課題解消のため、プロダクトにフィットする状態管理ライブラリの選定および導入を行い、プロダクトの品質改善に努めました。
前提
- 私たちのプロダクトでは、状態管理にContext APIを利用していた
- Context APIを利用していた背景は、追加パッケージが不要で、かつグローバルで管理する値が少なかったため
- プロダクトの規模感は小規模(マイクロサービスの一部)であり、サーバーステート(APIレスポンスのキャッシュなど)はTanstack Queryで管理している
- フレームワークには、Next.jsのPages Routerを採用している
課題感
Context APIの思想とのズレ
React公式では、「頻繁に状態が変わらないもの(ライト・ダークモード切り替えやログイン状態など)に対してContextを利用するように」と書かれており、複雑または過剰なContextの利用は避けるべきだと記載されています。 1, 2)
上記のルールを遵守していなかったこともあり、私たちのプロダクトではProvider地獄(ルートコンポーネントが多くのContextProviderを持っている状態)に見舞われていました。
以下に、Next.js(Pages Router)の_app.tsxでよく見られるProvider地獄の例を示しました。
<〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider> <Component {...pageProps} /> </〇〇ContextProvider> </〇〇ContextProvider> </〇〇ContextProvider> </〇〇ContextProvider> </〇〇ContextProvider> </〇〇ContextProvider> <〇〇ContextProvider> <〇〇ContextProvider>
Context APIの記述量の多さ
正しくContext APIを利用するなら、状態と状態変更関数のContextを分ける必要があります。分けなければ、利用しているContext以外の変更で意図しない再レンダリングを招いてしまいます。
ですので、意図しない再レンダリングを招かないようにするには、Context×2+Provider×2の定義が必須となります。
以下に、状態(例:スケジュールの日付)と状態変更関数(例:スケジュールの日付を変更する関数)をContext APIを用いて、記述した例を示しました。
import { FC, createContext, useContext, useState } from 'react' type ScheduleDateSetStateContextType = { setScheduleDate: React.Dispatch< React.SetStateAction<ScheduleDateStateContextType> > } type ScheduleDateStateContextType = Date export const ScheduleDateStateContext = createContext<ScheduleDateStateContextType>( new Date(), ) export const ScheduleDateSetStateContext = createContext<ScheduleDateSetStateContextType>({ setScheduleDate: () => undefined, }) export const useScheduleDateStateContext = () => useContext<ScheduleDateStateContextType>(ScheduleDateStateContext) export const useScheduleDateSetStateContext = () => useContext<ScheduleDateSetStateContextType>(ScheduleDateSetStateContext) export const ScheduleDateContextProvider: FC<{ children?: React.ReactNode }> = ({ children, }) => { const [scheduleDate, setScheduleDate] = useState<ScheduleDateStateContextType>(new Date()) return ( <ScheduleDateStateContext.Provider value={scheduleDate}> <ScheduleDateSetStateContext.Provider value={{ setScheduleDate }}> {children} </ScheduleDateSetStateContext.Provider> </ScheduleDateStateContext.Provider> ) }
また、正しい利用方法やProviderのラップ箇所についてチーム内で属人化していました。
状態管理ライブラリに求める要素
前提として、サーバーステート(APIレスポンスのキャッシュなど)はTanstack Queryに任せられているので、状態管理ライブラリはクライアントステート(FEにしか関わらない状態)のみ管理すればよい、ということになります。
ですので、責務が小さいことも鑑みた上で、求められる要素を以下に示しました。
※ 非同期、TypeScriptサポートや依存関係の少なさなどを前提条件としています。
小さい単位で取り扱い可能
状態と状態更新関数だけを作りたい、状態から派生した状態を作りたいなど、大きな粒度で管理するより、手軽に扱えるメリットの方が大きいため。
ボイラーテンプレートが少なく、APIが直感的で書き方の自由度が高くない
チーム内にリードエンジニアがいないことや、現状のプロジェクトのデリバリー速度を鑑みると、キャッチアップのしやすさが求められるため。
軽量
責務が少ない分、以下を考慮しました。 - できるだけパフォーマンスに影響を与えない - 万が一、ライブラリと相性が悪かった場合、クリティカルな問題が発生した場合にライブラリ自体の取り外しやすい
Reactアプリケーション内外での状態管理が可能
将来的に、エッジケース(オンラインとオフラインのステータスをサービスワーカーからReactアプリケーションに反映させるなど)に対応しなければいけない可能性がゼロではないため。
最終決定
Redux、Zustand、Jotai、Valtioを比較した上で、Jotaiを採用しました。
検討候補
Redux
- 特徴
- メリット
- 成熟したエコシステム
- Redux Toolkitを利用すれば、記述量が減る
- middlewareが提供されている
- 大規模開発であれば、厳格な規則を設けられる
- デメリット
- 記述量が多い
- Storeを複数作ることも可能であるが、手軽さに欠ける
- ルールの厳密性が高すぎるので、小・中規模のアプリケーションには向かない
- Redux Toolkitを利用したとしても、オーバースペック
Zustand
- 特徴
- メリット
- Redux思想を継承しつつ、冗長な部分を削ぎ落とし、開発者の負担を減らしている
- 導入コストが低い
- 記述量が少ない
- 書き方の自由度が高い
- Reduxと同じように1つのStoreを作成することが可能(Redux Toolkitのようにスライスを統合し、Storeを構築することも可能)
- 開発が活発に行われている
- デメリット
- 初期状態に対して、型推論が効かない
- 書き方にベストプラクティスがなく、ドキュメントやガイドラインを用意しないと、属人化してしまうリスクがある
Jotai
- 特徴
- Atom型
- Recoil思想
- useStateライク
- ミニマルな API
- Atom にユニークなキーを設定する必要がない
- createStoreを利用すれば、Reactアプリケーション内外での状態管理が可能
- middleware サポート
- React Suspenseと統合
- 4.7kB
- メリット
- Recoil思想を継承しつつ、冗長な部分を削ぎ落とし、開発者の負担を減らしている
- 導入コストが低い
- APIが直感的で、学習コストが低い
- TypeScript 指向
- 派生状態を作りやすい
- ドキュメントが充実している
- 開発が活発に行われている
- デメリット
- 認証処理など、複雑な状態遷移を伴うものに関して、細かいAtom群だと制御が難しくなる可能性がある
Valtio
- 特徴
- Store型
- Proxy ベース(プロキシは通常の JavaScript オブジェクトであり、どこからでも変更可能)
- Proxyの更新に伴い、再レンダリングさせるためにはuseSnapshotというhookを使ってproxyオブジェクトのsnapshotを取る必要がある
- JavaScript のオブジェクトをそのまま React の状態として使える
- React Suspenseと統合
- Mutable State Model
- 2.9kB
- メリット
- 記述量が少ない
- ライブラリ側で再レンダリングの最適化を自動で行ってくれる
- 導入コストが低い
- 書き方の自由度が高い
- デメリット
- Proxyベースは、どこからでも変更できるので、データの流れが読みづらくなる
- 破壊的メソッドなどもデフォルトで使用できたり、自由度が高すぎる状況が逆に扱いづらい
※ Store型、Atom型に関してはこちら
※ Recoilはメンテナンスが終了しているので、検討候補から除外しました。
評価表
小さい単位で 取り扱い可能 |
APIが直感的で、 ボイラーテンプレートが少ない |
書き方の自由度が 高くない |
軽量 | Reactアプリケーション内外での 状態管理が可能 |
総合評価 (5点満点) |
|
---|---|---|---|---|---|---|
Redux | ✕ | ✕ | ◯ | ✕ | ◯ | 2点 |
Zustand | ◯ | ◯ | ✕ | ◯ | ◯ | 4点 |
Jotai | ◯ | ◯ | ◯ | ◯ | ◯ | 5点 |
Valtio | ◯ | ◯ | ✕ | ◯ | ◯ | 4点 |
移行設計
既存Contextの移行方針
現状Context APIを利用している箇所は、マスタ、従業員情報やjwtといった業務影響が大きいかつ、既存コードベースへの参照が多く、修正が多い箇所となってます。 ですので、費用対効果は悪いと考え、移行はしないと判断しました。
Jotaiを組み込んだディレクトリ設計
初期運用は、シングルStore構成(Providerレスモード、デフォルトStore利用)としました。 私たちは、Package By Featureのディレクトリ構成をとっており、既存コードベースとの兼ね合いを考え、以下の形式で導入することにしました。
- Reactの外部での状態管理は、src直下のatomsディレクトリに格納する(全ページで利用するグローバルな状態は既存Contextで補えているため)
- 各feature内でグローバルに利用する状態は、src/features/(feature名)/atomsに格納する
- public - .env - src - ui //ドメインの知識を持たないコンポーネント (例 muiをwrapしただけのものなど、、) - pages // nextのページ - libs //ライブラリをアプリケーション用に設定して再度エクスポートしたもの(sentryのinitなど) - providers //アプリケーション全体で使用されるprovider(ContextおよびContextProviderの配置) - types //アプリケーション全体で使用される型 - utils //アプリケーション全体で使用される便利関数 - constans //アプリケーション全体で使用される定数 (環境変数など) - hooks // アプリケーション全体で使用されるhooks - styles // globalstyleを置くところ - (atoms) //*Reactの外部で状態管理が必要になった場合に作成 - src/features - feature名 - components - <component名> - presenter.ts //ロジックを持たない、propsを受け取りそれに応じたviewを返すだけのcomponent - presenter.stories.ts - presenter.test.ts - (container.tsx) //hooksやpresentational componentを組み合わせたコンポーネント、いわゆるcontainer component (*必要に応じて) index.tsx //entrypoint(最終的にexportするcomponent) index.test.tsx index.stories.tsx - hooks - <hook名>.ts - <hook名>.test.ts - types - <type名>.ts - (providers) //*必要に応じて - <provider名>.ts - (context)//*必要に応じて(atomsに移行) - <context名>.ts - (constans)//*必要に応じて - <constant名>.ts - (utils)//*必要に応じて - <util名>.ts - util.test.ts - (atoms)//*必要に応じて - <atom名>.ts
テストで利用するJotaiWrapper関数の作成
アプリケーション側ではデフォルトStoreを利用する想定なので、テスト時に挙動を調整したい場合は、以下の関数を用いて独自Storeを持ったProviderでラップしています。3)
import { createStore, Provider } from 'jotai' export const createJotaiWrapper = () => { const store = createStore() const wrapper = ({ children }: { children: React.ReactNode }) => ( <Provider store={store}>{children}</Provider> ) return { store, wrapper } }
状態管理におけるコーディング規約
2階層下にprops経由でデータを受け渡す場合は、積極的にAtomを定義する
私たちは各コンポーネントに対し、コンポーネントファイル、テストファイルおよびストーリーファイルを用意しています。 propsのバケツリレーをしてしまうと、propsに対する修正(props名、型名、型自体の変更)へのコストが大きくなってしまいます。 ですので、2階層下にprops経由でデータを受け渡す場合は、積極的にAtomを定義しましょう。という方針を定めました。
Hygen経由でAtomファイルを作成しよう
Hygen経由でAtomファイルを生成することで、ファイル名や定義方法の属人性を排除するよう努めました。 以下に、index.js、テンプレートおよび生成されるAtomファイルを記載しました。
// index.js module.exports = { prompt: ({ inquirer, _args }) => { const questions = [ { type: 'input', name: 'path', message: 'atomを入れるフォルダは? ex: src/features/', }, { type: 'input', name: 'name', message: 'atom名は? ex: sidebarInformationAtom', }, ] return inquirer.prompt(questions).then((answers) => { return { ...answers } }) }, }
// atom.ts.ejs.t --- to: <%= path %>/<%= name %>/index.ts --- import { atom } from 'jotai' export type <%= Name %>Type = {} export const <%= name %> = atom<<%= Name %>Type>()
生成されるAtomファイル
// scheduleDateAtom/index.ts import { atom } from 'jotai' export type ScheduleDateAtomType = {} export const scheduleDateAtom = atom<ScheduleDateAtomType>()
導入後
導入後、以下のような改善結果が得られました。
小さな状態を定義するのに必要な行数
行数 | |
---|---|
導入前(Context定義 + _app.tsxでラップするProvider(import含める)) | 42行 |
導入後(Atom定義) | 5行 |
propsに対する修正(props名、型名、型自体の変更)への変更ファイル数(n階層下のm個の子コンポーネントにprops経由でデータを受け渡していると想定)
ファイル数 | |
---|---|
導入前 | 1(props定義ファイル) + 3(コンポーネント、テスト、ストーリーファイル) ✕ n ✕ m |
導入後 | 1(atom定義ファイル) + m |
また、チームの先輩方にPR内で以下のような反応をもらい、やってよかったなと思いました。
- データの流れが把握しやすく、レビューしやすくなった。
- Provider地獄から救われた。
- 小さい状態を作ることへの懸念がなくなった。
- propsに対する修正で疲弊することが減った。
まとめ
最後まで読んでいただき、ありがとうございます。
小規模プロダクトにおけるReact状態管理ライブラリの選定および導入について紹介させていただきました。
バイセルは、新卒エンジニアに対しても技術検証、設計や品質改善への取り組みを主体的に行う機会を設けてくれています。
現在、FEエンジニアを募集しておりますので、少しでも興味のある方はぜひご応募ください。
参考記事
1) https://ja.legacy.reactjs.org/docs/context.html
2) https://zenn.dev/jotaifriends/articles/64652c94d84246
3) https://zenn.dev/akineko/articles/662bf324a10c82#%E3%83%86%E3%82%B9%E3%83%88%E6%96%B9%E6%B3%95