株式会社COMPASS
🛠️

フロントエンドの状態管理の改善に対する取り組み

2024/08/21に公開2

こんにちは!株式会社COMPASSの井上です。プロダクト開発ユニット、システム開発部、アプリケーション開発チームに所属しています。普段はキュビナマネージャー(QM)のフロントエンドエンジニアとして、新規機能の開発、既存機能の改修などを行っています。海外を放浪することが好きで、コロナの時には一度断念せざるを得ませんでしたが、最近ではベトナムやタイに滞在していました。今後は東南アジアの様々な国を訪問してみたいと思っています。

本記事では、キュビナのフロントエンド開発における状態管理について、現状抱えている課題とその解決方針について書いていきます。現在取り組み中の内容になっており、ある程度大きな方針については検討をしているものの、解決までの道のりはまだ少し長いかなという状況です。ですが、少しでも共通の課題を抱えている方々の参考になればと考えています。

この記事はこんな方におすすめ

  • キュビナ(特にQM)のフロントエンドの状態管理について知りたい方
  • フロントエンド、特に状態管理について課題を感じている方
  • 状態管理の課題への取り組み方の一例を探している方

背景

キュビナのフロントエンドはNext.jsを採用しています。Next.jsなどを使って開発する際にはフロントエンドでの状態管理が必要ですが、プロダクト開発を進めていく中で、画面によっては管理する状態の肥大化や複雑化が起こってしまっています。特に、ワークブックを編集・複製する機能の状態管理は非常に複雑で、不具合が発生しやすい状態になっています。


ワークブックとは?

ワークブックの機能の詳細についてはこちらを参照ください

フロントエンドとしては、キュビナに搭載されている問題の分類(章、節など)に基づいて、それに紐づいた問題リストなど様々な情報を管理しています。その他にも色々なデータを取り扱っていますが、データの種類が多いことに加え、データ構造も複雑なため、管理する状態が肥大化しやすい箇所となっています。そして現状としてはメンテナンス性が悪い状況なので、状態管理のあり方を抜本的に見直したいと考え、検討を始めました。

状態管理が複雑になっている要因

状態管理のあり方を見直すにあたって、まずは状態管理が複雑になっている要因をアプリケーションの特性、使用ライブラリの特性、コードの状態というところで考察を行いました。

仕様の複雑さ

まず、ワークブック機能の仕様がそもそも複雑であるという点があります。ワークブックの構造として、保持する問題、モード、配信時間、配信状態など様々なパラメーターを取り扱っています。例えば、ワークブックの問題選択のUIでは、章、節という問題の区切りごとに階層構造で問題を選択できる形になっています。問題を選択する際には問題ごと、節ごと、章ごとなど問題だけではなく区切りごとにチェックを入れることができ、チェックボックスが選択された際、選択されたチェックボックスの配下の問題も全て選択されるなど、UIとして複雑な仕様になっています。

また、ワークブックをキュビナマネージャーで作成し、作成したワークブックを編集する際の仕様も複雑です。キュビナは日々問題の解答状況などのデータから、品質に問題がないかをチェックし、改善を繰り返しています。その結果、問題の内容が変わったり、章や節の紐付けが変わったり、問題そのものがなくなるという状況も発生します。そうした際に問題の変更検知やチェックボックスの状態の再構築などをフロントエンドで行っており、それが複雑化の一因となっています。さらに、ワークブックを解くための目安の時間なども設定できるようになっていますが、そのための独自の状態なども保持しているため、全体的に構造が複雑かつ意識すべきポイントが多く、認知負荷が高い状態となっています。

Jotaiの使用に起因する課題

状態管理のライブラリとしてはJotaiを使っています。Jotaiを導入する前はMobxを使用していましたが、当時の開発状況的に、Mobxと比較してシンプルで使いやすいということで採用したライブラリです。しかし、実際に使用を続ける中でいくつかの課題が見えてきました。

依存関係の複雑化

Jotaiは上手に使えば非常に便利なライブラリではあるものの、利用時の課題は存在します。まず、Global Stateを扱うライブラリ全般に言えることではありますが、依存関係が複雑化してしまうという課題があります。具体的にはatom(Jotaiが扱うデータの単位)をどこからでも呼び出すことができ、どこからでも値を変更できるという柔軟性が、複雑化の要因となっています。誰がどこで何を呼んでいるのかがわかりづらくなってしまうことで、atomの値が変更された場合など、そのatomに関連する他のatomも含めて、それらを読み込む全てのコンポーネントを調べないと影響範囲がわからないという状況が発生してしまっています。

画面を跨ぐ際のデータの追いづらさ

画面を跨いでもデータが保持されるという特性から、ある画面で扱うatomが他の画面でも使われている時、遷移元の画面がどこかによってatomの初期値が異なることがあります。そのため、データの追跡が難しくなり、考慮すべき初期状態のパターンが増えて実装が複雑化してしまうという課題があります。この点については画面遷移時にuseEffectのクリーンアップでatomの値を初期化する方法を試したりなど試行錯誤は行いましたが、クリーンアップの実行タイミングと画面遷移後の初期レンダリングのタイミングが意図通りに動かないなどの問題があり、解決までには至りませんでした。

useEffectでJotaiに値をセットすることによるデータの追いづらさ

現状の構成ではJotaiに値をセットする際に、useEffectを使用している箇所があります。そういった箇所では一例として、APIの呼び出しを別のライブラリ(Tanstack Query)を用いて行い、APIから取得したデータをuseEffectを使ってJotaiに渡すといった処理を行っています。必要なシーンでuseEffectを使うことは問題ないのですが、useMemoやderived atom(Jotaiの他のatomの値から直接計算されるatom)を使用した方が良いシチュエーションにおいてもuseEffectを使用している箇所が多い状況です。useEffectが多用されると、データの流れを追うのが難しくなるので、できれば必要な場面でしか使わないようにしたいと考えています。

まず着手した対策

前章で記載した通り、状態管理が複雑になっている要因は様々ですが、まずは「画面を跨ぐ際のデータの追いづらさ」に焦点を当てて対策を講じることにしました。
方針としては、画面をJotaiのProviderでラップすることでスコープを区切り、Jotaiによる画面を跨いだデータ保持を回避したいと考えています。
そのために、まずはルートのスコープを作成し、画面を跨いで保持する必要のあるatomを必ずルートのスコープで扱うように修正しました。具体的には、アプリケーション全体をProviderでラップし、atomの呼び出しをそのProviderに紐づけたJotaiのStoreに固定しました。

AppProvider.tsx
////// アプリケーション全体をラップする用のProviderコンポーネントを作成

// Jotaiのstoreを生成
const appStore = createStore();

// 外部でstoreを使用するために、storeを返す関数をexportしておく
export const getAppStore = () => appStore;

// アプリケーション全体をラップする用のProviderコンポーネントを定義
export const AppProvider = ({ children }: { children: ReactNode }) => {
  // ProviderにappStoreを紐づけることで、このProviderによって作られるスコープにappStore経由でアクセスできるようになる
  return <Provider store={appStore}>{children}</Provider>;
};
pages/_app.tsx
////// _app.tsx(Next.jsにおいてエントリポイントとなるコンポーネント)で以下のように実装

export default function MyApp({ Component, pageProps }: AppProps) {
  // アプリケーション全体をAppProviderでラップする
  return (
    <AppProvider>
      <Component {...pageProps} />
    </AppProvider>
  );
}
sideMenuAtom.ts
////// 画面を跨いで保持する必要のあるatomを定義(例としてサイドメニューの開閉状態を管理するatom)

const appStore = getAppStore();

// Jotaiのatomを生成
// 直接exportはせずに下で定義しているカスタムフックをexportすることで、呼び出し側ではstoreを意識せずに使用できるようにする
const isSideMenuOpenAtom = atom(false);

// atomの値を取得するためのカスタムフック
export const useIsSideMenuOpenAtom = () => {
  // storeをappStoreに固定
  return useAtomValue(isSideMenuOpenAtom, { store: appStore });
};

// atomの値を更新するためのカスタムフック
export const useSetIsSideMenuOpenAtom = () => {
  // storeをappStoreに固定
  return useSetAtom(isSideMenuOpenAtom, { store: appStore });
};
DefaultLayout.tsx
////// 上で定義したatomの使用例

const DefaultLayout = ({ children }: { children: React.ReactNode }) => {
  const isSideMenuOpen = useIsSideMenuOpenAtom();
  const setIsSideMenuOpen = useSetIsSideMenuOpenAtom();

  const handleClickMenuButton = useCallback(() => setIsSideMenuOpen(true), [setIsSideMenuOpen]);
  const handleCloseSideMenu = useCallback(() => setIsSideMenuOpen(false), [setIsSideMenuOpen]);

  return (
    <Box>
      <SideMenu onClose={handleCloseSideMenu} open={isSideMenuOpen} />
      <Box>
        <SiteHeader onClickMenu={handleClickMenuButton} />
        <div>{children}</div>
      </Box>
    </Box>
  );
};

これを行った上で、下記のような形で画面毎にProviderでラップする対応を進めています。

pages/workbooks/[workbookId]/edit.tsx
////// ワークブック編集画面での実装例

const Content = () => {
  // ページ内で必要な処理やJotaiのatomの呼び出しはここに書く
  return (
    <DefaultLayout>
      <WorkbookSaveForm />
    </DefaultLayout>
  );
};

const WorkbookEditPage = () => {
  return (
    // ContentコンポーネントをJotaiのProviderで囲うことで、画面用のスコープを生成 
    <Provider>
      <Content />
    </Provider>
  );
};

export default WorkbookEditPage;

今回の対策を行った結果として、画面の初期レンダー時にatomの値が前の画面から引き継がれるケースを考慮する必要がなくなり、状態の認知負荷が軽減されました。
また、以下のように画面遷移時にatomを初期化する必要がなくなりました。

WorkbookSaveForm.tsx
////// 対策前

const WorkbookSaveForm = () => {
  const resetCheckIds = useSetAtom(resetCheckIdsAtom);
  const resetWorkbook = useSetAtom(resetWorkbookAtom);

  // 初期レンダリング後にatomを初期化
  useEffect(() => {
    resetCheckIds();
  }, [resetCheckIds]);

  // 画面のアンマウント時にatomを初期化
  useEffect(() => {
    // useEffectのクリーンアップ機能を使用
    return resetWorkbook;
  }, [resetWorkbook]);

  // ... 省略(他の処理) ...

  return <WorkbookSaveFormTemplate />;
};

export default WorkbookSaveForm;
WorkbookSaveForm.tsx
////// 対策後

const WorkbookSaveForm = () => {
  // atomの初期化が不要になった

  // ... 省略(他の処理) ...
  
  return <WorkbookSaveFormTemplate />;
};

export default WorkbookSaveForm;

これによって、コードがシンプルになり可読性が向上しました。また、今後は初期化の対応漏れやuseEffectのクリーンアップ関数を用いた初期化が期待通りのタイミングで動かないといったことに起因する不具合も発生しづらくなると予想されます。

今回の修正はまだ一部の画面にしか適用していませんが、一定の成果が見込めるため、この方針で他の画面も修正していきたいと考えています。

今後の展望

今後の対策として、まずはJotaiによる依存関係の複雑さの改善、atomの整理を行っていきたいと考えています。Global Stateの利点を活かしつつ、どこからでも呼び出せるという性質に起因する複雑さを解消するために、リファクタリングを進めていきたいです。

次に、APIの呼び出しをJotaiの中に組み込むことを検討していきたいと考えています。状態管理が複雑になっている要因の一つにuseEffectの多用を挙げましたが、現在はAPIから取得したデータをJotaiに受け渡す際にuseEffectを用いる必要があり、結果としてuseEffectを多く使ってしまっています。そのため、useEffectを使わなくてもJotaiでデータを保持できるように改善をしていきたいと考えています。具体的には、Jotaiの中でAPIの呼び出しを行い、レスポンスデータをそのままJotaiにセットする形を想定しています。

試行錯誤の結果、現在進めている方法とは異なる方針で最適化を行う可能性もあります。ですが、COMPASSのバリューであるBeyond the Bestの精神で、既存の採用技術や枠組みに囚われずに、常に最適なものを検討していきたいと考えています。COMPASSのバリューについては下記の記事をご覧ください。

COMPASSという会社がわかるオススメ記事3選〜MVVと組織風土〜
https://note.qubena.com/n/n3c9ab3d41383

社員一人ひとりの想いから紡いだCOMPASSの「価値観」。バリューができるまで、そしてこれからのこと。
https://note.qubena.com/n/n38780be85b63

また、COMPASSではこういった改善に一緒に取り組んでいただける仲間を募集中です。どの開発現場でも、コード品質が完璧で課題が何もないというところはないと思います。その中で、どのように最適化を進めるかを考え、実行することが重要だと考えています。本記事を読み、ご興味を持たれた方、お力になっていただけそうな方がいらっしゃいましたら、ぜひ採用ページもご覧ください。

Webフロントエンジニアスペシャリスト
https://hrmos.co/pages/qubena/jobs/4_1_2

Webフロントエンジニアミドル
https://hrmos.co/pages/qubena/jobs/4_1_15

まとめ

今回はフロントエンドの状態管理の改善に対する取り組みというテーマで、改善方針や現状の取り組みについてご紹介しました。この取り組みについてはまだまだ始まったばかりで、最適化までの道のりは長いとは考えていますが、取り組みが進んだ際にはまた記事としてご紹介させていただければと思いますので、ご興味のある方は是非今後の記事もご覧いただけますと幸いです。

最後に、ここまでお読みいただいてありがとうございました。本記事が皆様の課題解決の一助になれば幸いです。

株式会社COMPASS
株式会社COMPASS

Discussion

Kγ0suKεKγ0suKε

学習コストは高いですが、XState使うと結構useEffect減らせますのでおすすめです
https://xstate.js.org/

inoshuninoshun

コメントありがとうございます!
useEffectを減らせるのはとても魅力的ですね。XStateについてはあまり知見がないので、これを機に調べてみようと思います。
貴重なアドバイスありがとうございます!