⚙️

Zustand、君に決めた!! (AI Radio Makerの状態管理にZustandを選定した経緯)

2024/10/16に公開

TL;DR

  • AI Radio Makerでは画面横断での状態の管理があるのでグローバルな状態管理をしてます
  • 以下の理由でJotaiかZustandがいいと思った。
    • Next.jsのApp Routerを使っていると、できればProviderを使いたくなかった。
    • できるだけシンプルに使えるものがいい。
    • useReducer+useContextはProvider使うしボイラープレートが多く使いたくなかった
  • Atom型だとAtomを各々作りすぎて共通化するべきAtomも共通化されず、値が重複し、修正が大変になる可能性があると思い、Store型のZustandがいいとおもった。
    • 状態が増えたらSliceをつかったモジュール化しよう。
  • メンタルモデルが違うだけでできることは変わらないから好みで選択してもいいと思う。

はじめに

現在私はAI Radio Makerのフロントエンド開発をお手伝いしています。
https://airadiomaker.com

AI Radio Makerとは何かというと、
みなさんの好きなコンテンツのWebページのURLを入力するとラジオ番組形式の音声に変換するアプリです。
ものは試しなのでぜひ以下をチラ見してください。
https://youtu.be/oGqOUKaJtm4?si=C2Dtv_lerVHyZ6Xa

導入

近年、「状態管理ライブラリは何がいいか?」といった議論をあまり耳にしなくなりました。
その理由として以前までの状態管理のメインの使われ方である「サーバーのレスポンスデータをクライアント側でキャッシュ、管理する」みたいなところが、TanStack-QueryやSWRが出てきたことでこの辺をする必要がなくなってきたということをよく目にします。

Reduxのモチベーションを見ると「サーバーのレスポンスデータをクライアント側でキャッシュ、管理する」を解決筆頭に作れらているように読めます。
https://redux.js.org/understanding/thinking-in-redux/motivation

(日本語訳) JavaScript シングルページ アプリケーションの要件がますます複雑になるにつれ、コードではこれまで以上に多くの状態を管理する必要があります。この状態には、サーバー応答やキャッシュされたデータだけでなく、まだサーバーに保持されていないローカルで作成されたデータも含まれます。アクティブなルート、選択されたタブ、スピナー、ページネーション コントロールなどを管理する必要があるため、UI の状態も複雑さが増しています。

なので、モチベーションに書いてあることが解決されつつあるので使われなくなってきているのではないかと私は思っています。

こちらの記事もその歴史から考察されていて参考になりました。
https://www.nri-digital.jp/tech/20240829-18557/

しかしそんな流れの中、今私が開発しているAI Radio Makerでは状態管理ライブラリのZustandを使っています。
なぜ状態管理ライブラリを使っているのか?なぜZustandなのかをお話しできたらと思います。

グローバルな状態管理ってどんな時に必要?

そもそもグローバルな状態管理を持っておく場面ってどんな時でしょうか?それはライブラリを使う必要はあるのでしょうか?
Claudeさんと一緒にまとめてみました。

状態管理のニーズ 解決策 グローバルな状態管理必要? 理由 注意点
APIのキャッシュ APIのレスポンスデータをキャッシュとして保存 React QueryやSWRで対応可能 不要 専用のキャッシュライブラリが効率的 パフォーマンスと最新性を両立できる
サーバーに未保存のデータの一時保管 フォームの入力途中データ、ステップ間のデータ 状況に応じて:URLパラメータ、ローカルストレージ、状態管理ライブラリ、React Context 場合による データの性質、量、セキュリティ要件により最適な方法が異なる URLパラメータは共有可能だが長さ制限あり。ローカルストレージはページ間で永続化可能。状態管理はコンポーネント間の共有に適する
戻るボタン問題への対応 ページ遷移後のスクロール位置の復元 ブラウザAPI、React Router、カスタムフック 場合による ユーザー体験向上のため、前の状態を適切に再現する必要がある window.scrollToやuseLocationなどを組み合わせて実装。Next.jsではScrollRestoration機能も活用可能
フォームの状態管理 入力値やバリデーション状態 React Hook FormやConformなどの専用ライブラリ 不要 フォーム専用ライブラリが効率的 複雑なフォームの場合はグローバルな状態管理と組み合わせることも
広い範囲の画面横断での状態管理 テーマ設定、モーダルの開閉状態 状態管理ライブラリまたはReact Context 必要 アプリ全体で共有する状態の管理に適している アプリの規模に応じて適切なソリューションを選択
ユーザー認証情報の管理 ログイン状態、ユーザー情報 専用の認証ライブラリ(NextAuthなど) 不要 セキュリティとベストプラクティスを考慮した専用ソリューションが望ましい トークン管理、セッション維持、認証フローの標準化が可能

これを考えると「広い範囲の画面横断での状態管理」をする時には今もまだ必ず必要になりそうですね。
ただReactにはuseContextなるものがあるのでライブラリを使うかどうかはまた別の問題にはなりそうです。

AI Radio Makerではどんなところでグローバルな状態管理が必要か?

ではAI RadioMakerではどこでグローバルな状態管理を行なっているかを説明します。
以下の画面のように下にあるメディアプレイヤーはいつでも再生を止めたりすることができるものです。
まずここの情報はアプリを閉じてもずっと残しておきたいので永続化したいものです。

そしてこれ別のページからも参照したいです。例えばですが、新しく別のラジオを再生したいときは、この画面のように下にあるメディアプレイヤーも一緒に変わって欲しいです。

同じラジオを再生しているなら、それに応じて個別のラジオの画面でも停止するボタンなのか、再生するボタンなのかを切り替えできるようにしたいです。

ここまででもラジオデータはグローバルにかつ永続化したかったのです。

なぜZudstandを選んだの?

ではuseContextを使わずなぜ状態管理ライブラリのZustandを使ったのでしょうか?

useReducer+useContextは使いたくなかった。

ではじゃあそんなグローバルな状態を扱いたい時には、
真っ先にuseState+useReducer+useContextあたりが思いつくかと思います。

しかし、useReducer+useContextや、useState+useContextはかなりボイラープレートになる箇所が多く、実装も長くなりやすいと私は思っています。
※useStateなんて使うとかなりステートの管理も複雑になります。

例えばですが、

import React, { useReducer, useContext, createContext } from 'react';

const initialState = {
  count: 0,
};

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

const CountContext = createContext();

const CountProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <CountContext.Provider value={{ state, dispatch }}>
      {children}
    </CountContext.Provider>
  );
};

const Counter = () => {
  const { state, dispatch } = useContext(CountContext);
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
    </div>
  );
};

const App = () => {
  return (
    <CountProvider>
      <Counter />
    </CountProvider>
  );
};

export default App;

これに加えて永続化処理のためにLocalStorageに保存する処理を書こうとすると結構面倒だと思うわけです。useStateを使うともっと状態管理が複雑になるので辛いです。

逆にZustandで書くとですね

import create from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

const Counter = () => {
  const { count, increment, decrement } = useStore();
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
};

const App = () => {
  return (
    <>
      <Counter />
    </>
  );
};

export default App;


だいぶシンプルに書けます。永続化処理もmiddlewareのpersistを使うだけなのでそこまで大変ではないのです。

Providerは使いたくない。

それに加えて昨今Next.jsのApp Routerなどを使用していると、なかなかProviderを使うのは避けたいとは思いませんか?
ルートで"use client"をつけてProviderで囲むなどをしてもいいのですが、Next.jsの旨みがめちゃくちゃ減ってしまいます。
なのでできるだけProviderを設置したくなかったのです。(切実)

ここまで一旦まとめると

  • useReducer+useContextは冗長で使いたくない。
  • できるだけシンプルに使えるものがいい。
  • Next.jsのApp Routerを使っていると、できればProviderを使いたくなかった。

です。
ここまでで考えると、JotaiかZustandを選択するのがいいのではないかと思いました。

JotaiとZustandはどっち選ぶ?

まずぶっちゃけていうとWebフロントエンドは現状私一人で開発しているので正直どちらでもよかったです。もっというとどちらとも触ったことのなかったものなので、どちらがいいか正直わかりませんでした。
なので、かなりエアプで語ってます。
調べた範囲でどっちがいいかを書いていきますので、間違えた判断をしているかもです。その場合はすいません🙇

まずですが、グローバルな状態管理をするライブラリには2種類あり、Atom型とStore型です。
それぞれの特徴をぐっとまとめると

こちらの記事がわかりやすいので、読んでみてください。
https://zenn.dev/jotaifriends/articles/d714f9c16c1d3a

JotaiのようなAtom型の状態管理の利点は、グローバルな状態が必要なコンポーネント群のみ依存することができるので、グローバルな状態のスコープがそもそも制限できていい感じになることだと私は思っています。
とても良さそうです。スコープが狭いとそれだけ影響範囲もわかりやすいのでいいです。これいいですよね。
ただAIRadioMakerは会社としてのプロダクトなので、今後複数人で開発する可能性があることも考慮しておく必要があるとも思いました。
そうなると、将来Atom型だと以下のようなデメリットが起こると考えました。

  • Atomをみんな各々に作りすぎて共通化するべきAtomも共通化されず、値が重複し、修正が大変になる可能性がある。

これを考えるともう最初からStore型でトップダウン的な管理をしていき、状態が増えたらSliceパターンを使ってモジュール化するのがいいのではないでしょうか?
https://zustand.docs.pmnd.rs/guides/slices-pattern

Sliceについて

作者はSliceはこれを使うのを推しているらしい
https://github.com/zustandjs/zustand-slices

その方が私はシンプルに見えたのですが、今回はZustandを採用しました。
正直JotaiでもZustandでもどちらでも同じことはできると思うので、メンタルモデルの違いでどっち採用するか決めるのでいいと思っています。
例えば一つAtomしか作らないとするとそれはもうStore型になりますよね笑

あと参考にこのRedditが面白かったので載せておきます。
https://www.reddit.com/r/reactjs/comments/1ctsnov/why_choose_zustand_over_jotai/

ここまでをまとめると

  • AI Radio Makerでは画面横断での状態の管理があるのでグローバルな状態管理をしてます
  • 以下の理由でJotaiかZustandがいいと思った。
    • Next.jsのApp Routerを使っていると、できればProviderを使いたくなかった。
    • できるだけシンプルに使えるものがいい。
    • useReducer+useContextはProvider使うしボイラープレートが多く使いたくなかった
  • Atom型だとAtomを各々作りすぎて共通化するべきAtomも共通化されず、値が重複し、修正が大変になる可能性があると思い、Store型のZustandがいいとおもった。
    • 状態が増えたらSliceをつかったモジュール化しよう。
  • メンタルモデルが違うだけでできることは変わらないから好みで選択してもいいと思う。

最後に

Xやってるのでぜひフォローお願いします。

https://x.com/hudebakonosoto

Discussion