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/middleware の devtools を追加するだけで 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公式ドキュメント をご確認ください👀