hooks APIで整理することでReduxが不要に。asoview.comにおけるReact状態管理の変遷

こんにちはアソビューのフロントエンドエンジニアの井上です。
ヌルゲーマーなので満月の女王レオナを昨晩ようやく倒せてガッツポーズです。#エルデンリング

はじめに

Reactベースのフロントエンドの状態管理、よそはどうやってるんだろう。。
気になりますよね?私も気になります。
今回はアソビューにおける状態管理の変遷と今どのように扱っているか、について紹介したいと思います。
結論から言うとReactとその周辺のライブラリの変遷に振り回されましたが、hooks APIの登場でしがらみから開放されて(Reduxを使わずに)スッキリしました。
なので最初に言っておくと、Reduxやその他状態管理ライブラリの話はあまりしないです。。それらは誰にとっても必要なものではないのでは?というスタンスです。
共感できる方は「あるある」とか「それな」とか言いながら読んでいただけると幸いです。

これまでのアソビューのReact状態管理

クラスコンポーネントとflux(〜2017年頃)

私は5年ほど前に入社したのですが、その段階だとfacebook/fluxというfacebook社(現Meta社)の提唱したfluxのリファレンス的なライブラリを導入していました。
こちらのライブラリを実際に使っているところを今まで他で聞いたことがないので渋い選択をしたんだなとは思います。(経緯は今となってはわからないのですが)
また、Reactのコンポーネントは全てクラスコンポーネントで構成されていました。

関数コンポーネント、Redux、HoC構成へ (2018年以降)

fluxの使い方も冗長になってる部分もあったことや、状態管理として一般的に普及していなかったため活用事例も多くベストプラクティスなども世に溢れているReduxに書き換えていきました。
また、当時はHoC(Higher-order component = 高階コンポーネント)が流行(?)で、また、関数コンポーネントでもクラスコンポーネントで使えたローカルstateやcomponentDidMountなどのような各関数を使えるようにするrecomposeというライブラリが出てきて使い勝手が良かったので導入し、 関数コンポーネント、HoC with Redux & recomposeという構成で整理していきました。
また、Reduxの設計パターンとしてはDucksが見通しが良かったのでそちらを使ってました。

コンテナコンポーネント(HoCとして状態管理や副作用を管理)とプレゼンテーショナルコンポーネント(コンテナから状態やアクションをpropsとして受け取って宣言的にUIの変化を記述)を分けて複雑な状態管理があるようなページやコンポーネントでも煩雑で読みづらいコードにならないようにしていました。

大体こういう見た目でした。

// SomeComponent/index.js

export default compose(
connect(
  state => ({
    ...state.someModule
  }),
  dispatch => ({
    onClickXxxxx: () => {
      dispatch(someReduxAction())
    }
  }),
  mapProps(
     // ...
  ),
  withState(
     // ...
  ),
  withStateHandler(
     // ...
  ),
  lifecycle({
    componentDidMount () {
        // ...
    }
  })
))(SomeComponent)
// SomeComponent/SomeComponent.js

const SomeComponent = (props) => {
  return (
     {/* JSX */}
  )
}

export default SomeComponent

これらの構成で機能的には事足りていましたが、やはり記述が煩雑になったり手数が多く、シンプルさ、可読性でいうとネガティブな面が目立ってきました。

良かったこと

  • recomposeで関数コンポーネントのままでクラスコンポーネントの機能が使える!
  • Presentational Componentの宣言的UIに対してAPIからのデータ加工、画面操作による状態の変化などの処理はReduxやHoCに寄せることで分離することができ可読性が上がった
  • ajaxの非同期処理をReduxのモジュールの中で完結させ、Presentational Componentには一切それらの記述を書かないようにできた(Redux thunkを使いました)
  • Redux devtoolsで状態の変化がわかったりタイムマシーン機能でデバッグがしやすかった
  • reselectも合わせて使うことでメモ化によりレンダリングコストの削減することができた

課題

  • 手数が多い。
    • ducksパターンでReduxのファイルはドメインごとに一つで全てまとめたものの、やりたいことに対して回りくどくなりがち
    • API一本からデータを取得したいだけで、Reduxモジュールを作ってアクション定義して、コンポーネント側でcomponentDidMountでアクションdispatchして結果をmapStateToProps経由でもらって、、のような。
  • どこに何を書くか、が曖昧になってしまった。
    • HoCはあらゆるコンポーネントに使っていいのか?
    • それだと複雑になりそうなので一番上のPageコンポーネントでのみconnectする形にした。
    • そうなると下位のコンポーネントへのpropsリレーが発生してしまい、特にtypescript導入前だとミスが発生してしまった。typescript導入後は型定義が面倒なことに。。
    • Presentational Componentの状態はコンポーネント付きのHoC(recomposeのwithStateHandler)で持つのかReduxで持つのか?など。
  • これはよく言われていると思いますがHoCと上のコンポーネントから渡されたpropsの重複に気づけずバグにつながったりした。
  • Reduxモジュールのtypescript型定義が煩雑になりがちでany型に逃げてしまうこともあった。

そしてhooks APIの登場(2019年以降)

ある程度それらの構成で作って行きましたが、2019年2月頃にReact 16.8で待望のhooksの正式導入されました。
これまでやってきたことが全て標準のAPIでできるしとてもシンプル、、!

※「HoCは良い設計では無かった、おすすめしない」という趣旨の事をFacebookの中の人か誰かが言っていた気がしたのですがソース見つからず。。

hooks APIが使えるようになってどうなったか

HoCが全く不要になりました。

recomposeの各関数の置き換え

  • 下記の様にrecomposeのAPIは完全にhooks APIに置き換えることができたので変更
  • mapProps -> useMemo
  • withStateHandler -> useState
  • lifecycle + componentDidMount -> useEffect

Reduxの置き換え

  • まずはRedux hooksに変更してconnectのHoCを使わないように
  • reselectのメモ化の恩恵はuseMemoでまかなえるのでそちらに移行
  • 中途半端にビュー関連の状態も持ってしまっていた(例えばモーダル開閉とか)ものをコンポーネントごとのuseStateへ
  • あれ?APIのやり取りとその結果の管理しかしてないReduxモジュールが出てきたぞ。。。↓

REST API処理もhooks化

  • 弊社ではREST APIでデータを取得するケースが多いのですが
  • 以前の記事でも書きましたがREST API用のクライアントhooksを作りました。
  • また、キャッシュ機構が協力なswrをfetch用に優先的に使ってます。
    • postでの通信が必要だったり、環境によって認証情報が違うためswrをラップしたカスタムhooksを作って対応しています。
  • APIのやり取りしかしてないようなReduxモジュールはこちらに移行していきました。

tech.asoview.co.jp

swr.vercel.app

結局Reduxが必要なくなった

  • もちろん弊社の一部のアプリケーションでは、という注意書き付きです!
  • 上手く整理するとコンポーネントに対しての状態管理をそのコンポーネント内で完結させることができた
  • コンポーネントをまたぐ状態管理が必要なケースがそれほど無かった
  • コンポーネントを分割してコンポーネントをlazyロードするようにしてもReduxのStoreは分割できないというバンドルサイズ観点でデメリットがあり、しかもasoview.comはまだspringベースのMPAが中心のため、関係ない画面のReduxモジュールの重さが全画面に影響してしまっています。
  • これらから、カスタムhooksを適切な粒度で分けて組み合わせることで状態管理だけを切り出して整理する必要性があまり感じられなくなってきたため、新規の機能ではReduxを使わなかったり既存でReduxを使用しているものも修正のタイミングで解体していきました。

カスタムhooks活用している具体例

APIのレスポンスを加工して返す

  • JSON色付け係たるフロントエンドエンジニアであれば親の顔より見てきたパターンですね
  • 弊社では基本fetchはuseSWRを使います。(REST API)
  • 一箇所でしか使わず、かつAPIから来た値をそのまま使うのであれば関数コンポーネントのボティ部分にそのまま書くこともありますが、加工したりいくつかのコンポーネントで共用するなどの場合は切り出すことが多いです。
  • 例えば2つのAPIの結果をクライアントサイドでアグリゲーションする、とかだと下記のような形でしょうか。
  • Reduxでreselectで同じことやるとselector関数2つ作ってそれに依存するselector関数をuseSelectorで呼んで、、とかになると思いますがカスタムhooksだとシンプルに済みますね。
export const useSomethingApi = () => {
  const {data: dataApi1} = useSWR('/pass/to/api1')
  const {data: dataApi2} = useSWR('/pass/to/api2')

  return useMemo(() => {
    if (!dataApi1 || !dataApi2) return null
    return /* 何かdataApi1とdataApi2組合わせて複雑な加工する処理 */
  }, [dataApi1, dataApi2])
}
const someData = useSomethingApi()

認証情報を取得した結果をuseContextで管理して全体で使う

  • ログイン後にセッションcookieを使ってAPI通信し、会員情報などを取得する処理
  • ReactのuseContextとswrを使った通信をまとめて専用のContext Providerを作りました。
  • 基本的には一回APIリクエストしてその結果を受け取るだけなのでレンダリングパフォーマンスの心配も不要かと思います。

MemberContextProvider

const MemberContext = React.createContext<CombineMemberContext>(initialContext)

export const MemberContextProvider = () => {
  // useSWRをラップしたカスタムhooks
  const { data: memberInfoData } = useSWRGateway<MemberProfile>('/[会員情報問い合せAPI]')
  // 中略
  return (
    <MemberContext.Provider
      value={{
        ...initialContext,
        ...memberInfoData,
        // 中略
      }}
    >
      {children}
    </MemberContext.Provider>
  )
}

export function useMemberContext() {
  return useContext(MemberContext)
}

使い方

<MemberContextProvider>
  <App />
</MemberContextProvider>
const {isLogin} = useMemberContext()

{isLogin && <MemberFavoritePlans />}

コンポーネントとそれを制御する関数を共通化する(例えばモーダルなど)

  • これまでReduxを使っていたモーダルについてリファクタし、モーダルのコンポーネントと外からモーダルを開く関数をカスタムhooksで返すようにしました。
  • Reduxで作ってたときは、Reduxモジュールの中も使うコンポーネント側も結構な記述量でしたがこのくらいにまとめられました。

※口コミ投稿ボタンはいろんなとこに置くことができて、押すとモーダルが開くというもの

export const useReviewModal = () => {
  const [isReviewModalOpen, setReviewModalOpen] = useState(false)
  const { isLogin, isValidating } = useMemberContext()

  // モーダル開く用の関数。レビュー投稿機能はログインしてなかった場合はログイン画面に飛ばすのでその処理含む。元々はここもReduxのアクション経由で行っていた。
  const openModal = useCallback(() => {
    if (isValidating) {
      return
    }
    if (isLogin) {
      setReviewModalOpen(true)
    } else {
      fallBackLogin()
    }
  }, [isLogin, isValidating, setReviewModalOpen])

  // 同じくモーダル閉じる用
  const closeModal = useCallback(
    () => setReviewModalOpen(false),
    [setReviewModalOpen]
  )
  const ReviewModalHooks: React.FC<ReactModalHooksProps> = (props) => {
    return (
      <Suspense fallback={null}>
        {/* 別で定義してるモーダル本体。この中身はシンプルに別で作った共通のModalコンポーネントを利用してる。 */
        <ReviewModal {...props} isOpen={isReviewModalOpen} onClose={closeModal} />
      </Suspense>
    )
  }
  return [ReviewModalHooks, openModal, closeModal]
}

使うときはこれだけ。これを色々なページに配置していきました。

const [ReviewModal, openReviewModal] = useReviewModal()

<ReviewModal />
<Button onClick={openReviewModal}>
   口コミを投稿する
</Button>

状態管理の方針

なんでフロントエンドの状態管理のツールが必要なのか

個々のReactコンポーネントにおいては宣言的UIで描画するためにコンポーネントに渡されるpropsがあり、それを何らかの操作などで制御したくなったときに持つローカルステート(クラスコンポーネントのstate、FCでhooksで扱うならuseState)ということになるでしょうか。

それを扱うときの注意点としていかに無駄な再レンダリングを防ぐかということが特にアプリケーションが大きくなったときにユーザーの操作感や表示速度にも直結するための大きな関心事であると思います。

そのpropsの変化のタイミングをReduxなどのアクションdispatchに限ったりRedux内部でselectorを使うことで着実に制御するというのが状態管理ライブラリのメリットの一つということになると思います。
また、コンポーネントを跨いで(コンポーネントツリー上で親子関係に無いコンポーネント同士)同じ状態に依存するUIがある場合に、遥か上位からpropsをリレーしたりして見通しが悪くならないようにするというのも可読性やメンテナンス性観点のメリットです。単純にグローバルstateとして例えばAPIから取得したデータを反映したり同期したりという用途でも使えます。
グローバルstateとしての使用は誤ると昔多用されていたwindow領域のグローバル変数のようになってしまいますので最小限にしたいところです。。
アプリケーションが大規模になって複雑になっていくとルールが無いと副作用を含んだ実装をされてしまいどんどん可読性が下がっていく実感はあります。

というわけで、まとめると大規模なアプリケーションにおいてパフォーマンスを落とさずに、実装ルールがつけやすくなるので、誰がやっても開発しやすく壊れにくいようにするというのが状態管理を切り出すメリットだと理解しています。

※ただ、開発しやすい、でいうとReduxは使う上でのボイラープレートが大きいというデメリットがあり、そういう観点でも今だとRecoilは最有力になるかとは思います。

asoview.comにおける状態管理

asoview.comはアクティビティやチケットの検索、予約サイトです。
サーバーとクライアントで頻繁に(例えば数秒単位やそれ以下)情報の取得、更新を行うほどのリアルタイム性のある要件がそこまで多くありません。(在庫など一部はその傾向はあるものの)

「状態」と一言で言っても解釈は多岐にわたると思いますが、asoview.comでは基本的にはページを開いたタイミングでのサーバー(DB)のデータと同期する、必要ならそのデータを加工した上で唯一の情報源として宣言的UIの関数コンポーネントに渡して表示する。

それに対しての検索や購入などの行動を完結させるためのUIのためにコンポーネントの状態が変化していくような流れであり、一連のUIの中でサーバーとの同期や更新などが必要なタイミングはそれほど多くありません。
サーバーとの同期という観点ではswrのkeyベースのキャッシュ機構が優秀でReduxでグローバルstateとして扱っていたようなAPIレスポンスの結果をswrで管理して同期を取るやり方ができています。(swrはとてもお気に入りのライブラリなのでまた別で語りたい。。)

そのため、基本的にはReduxやRecoilなどのReactの外で状態を管理するライブラリは使用せず、Reactの中でカスタムhooksを利用して管理する形を推進しています。

メモ化などで気をつければレンダリングパフォーマンスにも影響無くできると思います。(ここに関しては目を光らせてないと行けないですが)
範囲や更新回数が限定できれば、useContextを活用してコンポーネントツリーの各階層でコンテキストを共有するやり方も有効だと思います。

また、アソビューではB向け(施設パートナー向け)管理画面なども開発しておりそちらではもう少し複雑な状態が必要になる機能を作る可能性はあります。
ただ、現状HoC with Redux & recomposeになっていますがasoview.com同様そこまで状態管理を切り出すことが必要なロジックは無さそうです。

これらから基本的にはReactの標準API(useState, useEffect, useContext, useRefなど)で完結させるように必要に応じてカスタムhooksを作成して整理しています。

hooksで完結させる上で気をつけてること

  • 不必要に再レンダリングを招くstateをuseStateで作ってないか?
    • 直接UI変化に関与しないのであればuseRefを用いる
  • 関数コンポーネントでuseEffectを安易に使ってないか?
    • Reduxで整理していたときにuseEffect + useStateを組み合わせて使ってしまってdepsの副作用地獄(?)になってたことがあるので。。useStateは明示的なユーザーアクションで変化させるようにした方が良いと思います。
  • カスタムhooksの機能はI/Oは妥当か?
    • テストしやすく整理できているか?副作用とそうじゃない部分を上手く分ける。

まとめ

アソビューでは基本的にはバンドルサイズなどのことも考えてライブラリなどの導入は必要最低限にしています。

まだ、先が見越せない段階で安易にライブラリを導入を決めてしまうのは容易に引き返せない(技術的にというよりは一度作ってしまうと引っ剥がしたり別のライブラリの移行する時間や工数を捻出できないという面が多いです。)デメリットがあるので良くないと思っています。

これからも流行っているかどうか(もちろんこれも大事ではあるそうではないfacebook/fluxの扱いなどは苦労しましたので)以上に必要かどうか、他にライトな選択肢は無いかを考えながら設計していきたいと思います。


さて、アソビューではまだまだ変化の早いフロントエンド、Reactにおいて私達と一緒にあーだこーだいいながら設計、実装を進めてくれる仲間を探しております!
興味のある方、ちょっと話を聞いてみたいと思った方は、ぜひ下記ページをご覧いただきお気軽にエントリーいただければと思います。(まずはカジュアルに面談させてもらえればと思っております。)

www.asoview.com