バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

小規模プロダクトにおける React 状態管理ライブラリ選定 in 2024

はじめに

こんにちは! テクノロジー戦略本部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

  • 特徴
    • Store型
    • Flux アーキテクチャ
    • View,Action,Dispatcher,Storeの4つの要素で構成され、単方向データフローを構築
    • 信頼できるのは唯一の情報源であり、状態はRead Only(読み取り専用)
    • 状態の変更は純粋関数で行われる
    • middleware サポート
    • React Suspenseと統合
    • 1.6kB + 4.7kB (Redux + React-Redux)
    • 13.5kB (Redux Toolkit)
  • メリット
    • 成熟したエコシステム
    • Redux Toolkitを利用すれば、記述量が減る
    • middlewareが提供されている
    • 大規模開発であれば、厳格な規則を設けられる
  • デメリット
    • 記述量が多い
    • Storeを複数作ることも可能であるが、手軽さに欠ける
    • ルールの厳密性が高すぎるので、小・中規模のアプリケーションには向かない
    • Redux Toolkitを利用したとしても、オーバースペック

Zustand

  • 特徴
    • Store型
    • Redux 思想
    • Hooks を使用した状態管理
    • 超軽量
    • JavaScript Rising Stars の状態管理部門で2年連続1位
    • middleware サポート
    • React Suspenseと統合
    • 1.1kB
  • メリット
    • 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を定義しましょう。という方針を定めました。

propsのバケツリレーの改善図

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エンジニアを募集しておりますので、少しでも興味のある方はぜひご応募ください。

herp.careers

参考記事

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