🐻

JotaiプロジェクトをZustandで作り直した話

2024/12/27に公開

ゆめみでフロントエンドエンジニアをしているゆーけんです🐈

新卒一発目でアサインされた案件で、Jotaiで状態管理されているプロジェクトをZustandで作り直すというパワー系リファクタを経験したので記事に書きます。

開発したプロダクト

  • 一覧画面
  • 新規作成画面
  • 編集画面

がある以下の画像のような管理画面を、先輩と2人で0から開発しました。

一覧画面
一覧画面

新規作成画面
新規作成画面

詳細画面
詳細画面

フォームの値をリアルタイムでプレビューに表示するため、フォームの値をJavaScriptで管理する必要があります。

  • コンテンツの値を状態管理したい
  • コンテンツは新規作成、削除される
  • コンテンツのプロパティ入力フォームに様々なバリデーションを実装したい

これらの条件はJotaiのatomFamilyを使うことで柔軟に設計できそうだったので、はじめはJotaiを使って設計・実装しました。

Jotaiで実装する

設計

コンテンツのタイトルというプロパティを例に設計を見ていきます

タイトル入力フォーム

atomFamilyを使い、ボトムアップ的に状態管理を行うことを考慮して以下のようにディレクトリ構造を設計しました。

content
└ form
  └ title
    ├ atom.ts <= タイトルプロパティに関するatomFamilyを複数定義
    ├ hook.ts <= useAtomを使って値のsetterなどを定義
    └ index.tsx <= コンポーネント本体

atom.ts内部では

  • タイトルの値
  • バリデーション結果のboolean
  • バリデーションのエラーメッセージ配列

のatomFamilyを定義しています。

atom.ts
export const contentTitleAtomFamily = atomFamily((_id: string) =>
  atom(''),
);

/**
 * その時点でのバリデーションをすべて通過しているかどうか
 */
export const contentTitleIsValidAtomFamily = atomFamily((id: string) =>
  atom((get) => {
    ...
    return バリデーション結果boolean
  }),
);

/**
 * その時点でのバリデーションエラーメッセージの配列
 */
export const contentTitleErrorMessagesAtomFamily = atomFamily(
  (id: string) =>
    atom((get) => {
      return バリデーションのエラーメッセージ配列
    }),
);

最終的にformの値を送信するときは、各プロパティのatomを集約したオブジェクトを生成するようなイメージです。

export const contentFormValueAtomFamily = atomFamily((id: string) =>
  atom(
    (get) => {
      // form内のすべての値を集約する
      const adminTitle = get(contentAdminTitleAtomFamily(id));
      const thumbnailUrl = get(contentThumbnailURLAtomFamily(id));
      const title = get(contentTitleAtomFamily(id));

嬉しみ

ストアの定義がわかりやすい

タイトルプロパティ関連のストアの定義はすべて、title/atom.tsに書かれているのでとてもわかりやすいです。上の層でこれらを集約する、まさにボトムアップというような設計。

つらみ

atomFamilyが思ったより扱いにくい

公式ドキュメントにも書いてある通り、atomFamilyは内部的には単なるMapです。なので、複数のIDのコンテンツを開けば開くほどオブジェクトが生成され、メモリリークを発生させる可能性があります。

なので

/**
 * atomを破棄する
 */
export const disposeContentTitleAtomFamily = (id: string) => {
  contentTitleAtomFamily.remove(id);
  contentTitleIsValidAtomFamily.remove(id);
  contentTitleErrorMessagesAtomFamily.remove(id);
};

atomを破棄する関数を用意し、コンテンツ詳細を離れるタイミングで呼ぶ実装を考えました。
ただ、どことなく漂うイケてない感...

関心の散らばり

値やバリデーションなどのすべてにおいて、ストアを作りまくることでかなり柔軟に設計することができます。ただ、なんだか直感に反する気持ち...formの値はやはり1つのストアでまとめて管理したい。

作り直そう

そんな中Jotaiと、同じ作者さんのZustandを発見。

シンプルなAPIで、Jotaiでもやもやしていることが大体解決しそう!

ほな、Zustandでやり直そう!

この判断ができたのも、scaffdogを使った効率的な開発フローのおかげなのですが、詳細は先輩の記事をご覧ください。

https://zenn.dev/yumemi_inc/articles/ef89ef41091ddf

Zustandで実装する

設計

Jotaiの時のように大量のストアを作るのではなく、1コンテンツのデータを丸ごと1つのストアで管理します。
今回は、公式でも推奨されているようにRefにストアを格納し、そのRefをContextで管理する手法を取りました。

export const ContentStoreProvider = ({
  children,
}: ContentStoreProviderProps) => {
  const storeRef = useRef<ContentStoreApi>()
  if (!storeRef.current) {
    storeRef.current = createContentStore()
  }

  return (
    <ContentStoreContext.Provider value={storeRef.current}>
      {children}
    </ContentStoreContext.Provider>
  )
}

また、トップダウンの状態管理ではストアが複雑になりがちなので、Zustandのsliceパターンを利用します。これにより、実質のストアは1つですが、各プロパティのディレクトリにそのプロパティのストアの定義を書くことができます。

content
└ form
  └ title
    ├ slice.ts <= タイトルプロパティに関するストアをsliceパターンで定義
    ├ hook.ts <= ストアの値のgetter, setterを定義
    └ index.tsx <= コンポーネント本体

ディレクトリ構造はJotaiの時と似ています。slice.ts内部では

  • タイトルの値
  • タイトルの値のsetter
  • バリデーション結果のboolean
  • バリデーションのエラーメッセージ配列

をsliceパターンで定義しています。

slice.ts

// titleプロパティに関するストアの型
export type ContentTitleSlice = {
  title: string;
  setTitle: (title: string) => void;
  getTitleErrorMessages: (value: string) => string[];
  getTitleIsValid: () => boolean;
};

export const createContentTitleSlice: FormInputSliceCreater<
  ContentTitleSlice,
  { title: string }
> = (initalValue) => (set, get) => ({
  title: initalValue.title,
  setTitle: (title) => set({ title }),
  getTitleErrorMessages: (value) => {
    return getValidationErrorMessage({
      validations: contentTitleValidation(value),
    });
  },
  getTitleIsValid: () => {
    const value = get().title;
    const errorMessages = get().getTitleErrorMessages(value);
    return errorMessages.length === 0;
  },
});

細かい実装は省略しますが、ContentTitleSlice型のストアを定義しています。

最終的にはこのストアの値を加工して送信することになります。

嬉しみ

リクエスト単位のストア

ストアがcontextに乗っているので、リクエストごとにストアが破棄されます。atomFamilyの時のように、ストアの状態を気にせずに開発できるのはかなり嬉しいです。

1ストアで直感的

コンテンツ詳細ページを開くたびに、コンテンツのプロパティをもつストアが作成されます。フォームを修正するたびに、ストアのプロパティが変更され、最終的にストアの値を送信。かなり直感的でシンプルにオブジェクトの状態管理ができているのではないでしょうか。

つらみ

ネストされたプロパティがちょっとしんどい

sliceパターンを多重にネストされたプロパティに応用しようとすると、型定義がどうにもうまくいかない場面がありました。

{
  hoge: {
    fuga: {
        piyo: {
            foo: 'foo'
        }
    },
  },
};

例えば、このようなオブジェクトでpiyoディレクトリをsliceパターンで書くとしましょう。
そうなるとおそらくsetterは下のようになります。

piyo/slice.ts
set({
  hoge: {
    fuga: {
        piyo: {
            foo: value
        }
    }
  }
});

zustandではストアは一つなので、piyoは自分がどの深さでネストされているのか親のプロパティ名を知らないとsetterが書けません。こうなると設計が破綻します。

これを回避するためにオブジェクトはネストせず、プロパティ名で識別する方法を取りました。

{
  hogeFugaPiyoFoo: 'foo'
};

これならネストを気にせずにsetterを書くことができます。ここはちょっと工夫が必要でした。

まとめ

状態管理ライブラリ つらみ 嬉しみ 使い所
Jotai メモリリーク(atomFamily)、管理が煩雑になりがち 柔軟な設計が可能 ユーザ情報などグローバルだが、シンプルな値
Zustand ネストされたプロパティは工夫が必要 1つのストアでシンプルにオブジェクトを状態管理できる formなどの複雑なオブジェクト

Jotaiのボトムアップ的な状態管理を導入することで、かなり柔軟にオブジェクトの状態管理をすることができました。ただ、atomFamilyに若干クセがあったり、関心が散らばってしまったりすることがありました。

一方、Zustandを使うことでシンプルに複雑なオブジェクトの状態管理を行えたかなという印象です。

結果的に、Zustandを持ち上げるような記事になってしまいましたが、手軽さ、柔軟さではJotaiに軍配が上がると思います。メインの複雑なformはZustandで管理し、そのほかの細かい状態管理はJotaiで行うのもかなりイケてると思います。

株式会社ゆめみ

Discussion