こんにちは、かつコーチです。
前回は、状態管理ライブラリの定番であるRedux Toolkitの入門編を解説しました。
Redux Toolkitは強力な反面、slice の作成・store の設定・Provider の配置など、覚えることがそれなりに多いのも事実です。
「もっとシンプルに、少ないコードでグローバル状態を管理したい」という人に人気なのが、今回紹介するZustandです。
ドイツ語で「状態」を意味するこの名前の通り、状態管理そのものに機能を絞ったシンプルなライブラリになっています。
Zustandとは?
Redux Toolkitとの違い
Zustandは、Redux Toolkitと同じく「アプリ全体で共有する状態」を管理するためのライブラリですが、設計思想がかなり異なります。
| Redux Toolkit | Zustand | |
|---|---|---|
| Providerの設置 | 必要(<Provider store={store}>) | 不要 |
| 状態の定義方法 | createSlice でAction・Reducerを分けて定義 | create で状態と更新関数をまとめて定義 |
| ボイラープレート | 少ない(従来のReduxよりは) | さらに少ない |
| DevTools連携 | 標準で対応 | ミドルウェアを追加すれば対応 |
| 学習コスト | やや高い | 低い |
一番の違いは、Providerでアプリを囲む必要がないという点です。
Zustandは「状態を持ったカスタムフック」を作るようなイメージで使え、Reactのコンポーネントツリーの構造に依存しません。
どんな場面で使うのか
Zustandが向いているのは、次のようなケースです。
- 中小規模のアプリで、素早く状態管理を導入したい場合
- Redux Toolkitほどの学習コストをかけたくないチーム
- Context APIだと再レンダリングの最適化が面倒に感じてきた場面
この使い分けの判断軸についても、次回の比較記事でさらに詳しく整理していきます。
基本の書き方
storeを作成する
まずはインストールします。
npm install zustand
Zustandの最大の特徴は、create 関数1つでstoreを定義できることです。
import { create } from "zustand";
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
状態(count)と、それを更新する関数(increment・decrement・reset)を、同じオブジェクトの中にまとめて定義しているのが分かると思います。
Redux Toolkitのように「Action」「Reducer」「Store」を別々に用意する必要がなく、1つの create 呼び出しで完結しています。
コンポーネントで使う
作成した useCounterStore は、通常のカスタムフックのようにそのままコンポーネントで使えます。
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<p>カウント:{count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
);
}
<Provider> でアプリを囲む必要がない点に注目してください。
useCounterStore はどのコンポーネントからでも直接呼び出すだけで、同じ状態を共有できます。
必要な値だけを取り出す(セレクター)
useCounterStore((state) => state.count) のように、関数を渡して必要な値だけを取り出す書き方をセレクターと呼びます。
// 複数の値をまとめて取り出す場合
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}));
これはRedux Toolkitの useSelector と同じ役割で、必要な値だけを取り出すことで無駄な再レンダリングを防ぐ効果があります。
つまずきやすいポイント:storeオブジェクト全体を取得してしまう
かつコーチが実際にハマった話
私がZustandを初めて使ったとき、「セレクターを書くのが面倒」という理由で、storeの中身をまるごと受け取る書き方をしてしまい、Redux Toolkitのときと同じ「関係ない値の更新でも再レンダリングされる」問題を再び踏んでしまったことがあります。
❌ Before:storeの中身をまるごと取得する
function Counter() {
// store全体を取得しているため、countとは無関係な値の更新でも再レンダリングされる
const store = useCounterStore();
return (
<div>
<p>カウント:{store.count}</p>
<button onClick={store.increment}>+1</button>
</div>
);
}
Zustandは useCounterStore() のように引数なしで呼び出すこともできてしまうため、つい手軽さに流されてこの書き方をしてしまいがちです。
しかしこの場合、storeの中のどの値が更新されてもコンポーネントが再レンダリングされてしまいます。
✅ After:セレクターで必要な値だけを取得する
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<p>カウント:{count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
Zustandを使うときは、「引数なしで呼べるから」という理由で省略せず、必ずセレクターを使って必要な値だけを取り出す習慣をつけましょう。
storeの規模が大きくなるほど、この違いがパフォーマンスに響いてきます。
応用・一歩先の使い方
非同期処理を組み込む
Zustandは特別な仕組みを使わなくても、非同期関数をそのままstoreの中に書けます。
type UserState = {
data: { id: number; name: string } | null;
isLoading: boolean;
fetchUser: (userId: number) => Promise<void>;
};
const useUserStore = create<UserState>((set) => ({
data: null,
isLoading: false,
fetchUser: async (userId: number) => {
set({ isLoading: true });
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
set({ data, isLoading: false });
},
}));
Redux Toolkitの createAsyncThunk のような専用の仕組みを覚える必要がなく、通常の非同期関数として set を呼ぶだけでよいのが、Zustandの手軽さを象徴する部分です。
ミドルウェアで永続化やDevTools連携を追加する
Zustandはミドルウェアを重ねることで、機能を後から追加できます。
import { create } from "zustand";
import { persist } from "zustand/middleware";
const useSettingsStore = create<{ theme: "light" | "dark"; toggleTheme: () => void }>()(
persist(
(set, get) => ({
theme: "light",
toggleTheme: () => set({ theme: get().theme === "light" ? "dark" : "light" }),
}),
{ name: "settings-storage" } // localStorageに保存するキー名
)
);
persist ミドルウェアを重ねるだけで、状態が自動的に localStorage に保存され、ページを再読み込みしても状態が復元されるようになります。
必要な機能だけを後から足していける柔軟さも、Zustandが支持されている理由の一つです。
まとめ
この記事のポイント
- Zustandは、
create関数1つで状態と更新関数をまとめて定義できるシンプルな状態管理ライブラリ - Redux Toolkitと違い、
<Provider>でアプリを囲む必要がない - セレクターを使って必要な値だけを取り出すことで、無駄な再レンダリングを防げる
- 非同期処理も特別な仕組みなしで通常の関数として書ける
persistなどのミドルウェアで、永続化やDevTools連携を後から追加できる
次に読むべき記事
次回は、Redux・Zustand・Context APIをどう使い分けるべきか、比較しながら整理していきます。
→ 次の記事:Redux・Zustand・Context API、結局どれを使うべき?