【React】Zustand入門:シンプルな状態管理ライブラリ

JavaScript

こんにちは、かつコーチです。

前回は、状態管理ライブラリの定番であるRedux Toolkitの入門編を解説しました。

Redux Toolkitは強力な反面、slice の作成・store の設定・Provider の配置など、覚えることがそれなりに多いのも事実です。

「もっとシンプルに、少ないコードでグローバル状態を管理したい」という人に人気なのが、今回紹介するZustandです。

ドイツ語で「状態」を意味するこの名前の通り、状態管理そのものに機能を絞ったシンプルなライブラリになっています。

Zustandとは?

Redux Toolkitとの違い

Zustandは、Redux Toolkitと同じく「アプリ全体で共有する状態」を管理するためのライブラリですが、設計思想がかなり異なります。

Redux ToolkitZustand
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)と、それを更新する関数(incrementdecrementreset)を、同じオブジェクトの中にまとめて定義しているのが分かると思います。

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、結局どれを使うべき?

タイトルとURLをコピーしました