React のグローバル State を Zustand に切り替えたら、useContext のボイラープレートが消えた話

複数コンポーネントで共有する State を useContext で管理していたプロジェクトに Zustand を導入したところ、Provider の入れ子が消え、コードがかなりスッキリしました。

🙌 結論から

useContext で管理していたグローバルな State を Zustand に移行したところ、Provider の入れ子とカスタムフックの定型コードがかなり減りました!

小規模なプロジェクトでは useContext で十分なのですが、State の種類が増えてくると Context のネストが深くなりがちで、コンポーネントツリーを追いかけるのが正直しんどくなってきます(´・ω・`)

Zustand はシンプルなストアを定義するだけで、どこのコンポーネントからでも値を取り出せます。

Provider を設置する必要もないため、アーキテクチャの見通しがかなり良くなった感じです。

😓 useContext の悩みどころ

useContext 自体は React の標準機能で、外部ライブラリなしにグローバル State を扱えるのは便利です。

ただ、複数の Context を組み合わせると Provider の入れ子が深くなりがちで、コードが読みにくくなってきます。

<AuthContext.Provider value={auth}>
  <ThemeContext.Provider value={theme}>
    <NotificationContext.Provider value={notification}>
      <App />
    </NotificationContext.Provider>
  </ThemeContext.Provider>
</AuthContext.Provider>

こうなると「あの State はどの Context に入っていたっけ?」と追いかける手間が増えます。

また、Context の値が変わると配下のすべてのコンポーネントが再レンダリングされるため、パフォーマンスの考慮も必要になってきます。

これが積み重なって「もう少し楽に書けないかな…」と感じるようになりました(・_・;)

🚀 Zustand で書き直した

Zustand のストアは TypeScript で以下のように定義します。

import { create } from 'zustand';

type UserState = {
  name: string;
  isLoggedIn: boolean;
  login: (name: string) => void;
  logout: () => void;
};

export const useUserStore = create<UserState>((set) => ({
  name: '',
  isLoggedIn: false,
  login: (name) => set({ name, isLoggedIn: true }),
  logout: () => set({ name: '', isLoggedIn: false }),
}));

使う側はシンプルで、Provider の設置が不要なのが特に気持ちいいです!

function Header() {
  const { name, isLoggedIn, logout } = useUserStore();
  return isLoggedIn ? <button onClick={logout}>{name}</button> : null;
}

ストアごとにファイルを分けておくと、「どこで何を管理しているか」がひと目でわかりやすくなります。

複数の Context ファイルを行ったり来たりしていた頃と比べると、かなり快適になりました(^o^)/

✨ 使ってみた感想

実際に使ってよかった点をいくつか挙げてみます。

不要な再レンダリングが減った

セレクターで必要な値だけを取り出せるため、関係ない State が変わっても再レンダリングが発生しません。

// isLoggedIn だけを購読する場合
const isLoggedIn = useUserStore((state) => state.isLoggedIn);

これは useContext にはない強みで、パフォーマンス面での安心感があります!

ストアのテストが書きやすい

ストアがただの関数なので、Vitest や Jest でそのまま動作を確認できます。

カスタムフックのテストに必要な renderHook を使う必要もなく、サクッとテストを書けます。

devtools との連携が楽

zustand/middlewaredevtools を追加するだけで Redux DevTools から State の変化を追えます!

デバッグ中に「この State がいつ変わったか」がひと目でわかるのはかなりありがたいです。

💾 persistとslicesパターンでストアを育てた

ストアの数が増えてきたところで、永続化分割の2つの課題に向き合うことになりました。

まず永続化です。ログイン状態のような、リロードしても消えてほしくない State には persist ミドルウェアを使います。

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      name: '',
      isLoggedIn: false,
      login: (name) => set({ name, isLoggedIn: true }),
      logout: () => set({ name: '', isLoggedIn: false }),
    }),
    { name: 'user-storage' } // localStorage のキー名
  )
);

これだけで自動的に localStorage に同期されて、ページをリロードしてもログイン状態が保持されるようになりました(・∀・)

もう一つの課題が、1つのストアファイルがどんどん肥大化していく問題です。

これは slicesパターンで解決しました。関心ごとにストアを分割してから、最後に1つに合成するやり方です。

// userSlice.ts
const createUserSlice = (set) => ({
  name: '',
  login: (name) => set({ name }),
});

// cartSlice.ts
const createCartSlice = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});

// store.ts(合成する側でだけ persist を掛ける)
export const useStore = create<UserSlice & CartSlice>()(
  persist(
    (...a) => ({
      ...createUserSlice(...a),
      ...createCartSlice(...a),
    }),
    { name: 'app-storage' }
  )
);

ここで一つハマったのが、persist のようなミドルウェアは個々のスライスにではなく、合成した後のストア全体に1回だけ適用するという点です。

最初はスライスごとに persist を掛けようとして、状態がうまく同期されずに悩みました(^^;)

「関心ごとにファイルは分けるけど、ミドルウェアは合成後にまとめて掛ける」というルールさえ覚えておけば、ストアがどれだけ大きくなっても見通しよく管理できています。

パパ系Webエンジニア〜愛用ガジェット一覧 🛍️

この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌

🙌 まとめ

Zustand は 小さなボイラープレートで始められる React のグローバル State 管理ライブラリです。

useContext のネストに悩んでいる方や、Redux は大げさに感じている方には特におすすめしたいです🙌

TypeScript との相性もよく、型定義も自然に書けるため、個人開発から小〜中規模のプロジェクトまで導入しやすいと思います。

試してみて「あ、こういうことか」ってなる感じ、ぜひ味わってみてください!

※ Zustandのバージョンによって使えるミドルウェアが異なります。最新情報は Zustand公式ドキュメント をご確認ください👀