Zustandを理解する

に公開

概要

zustandは軽量・高速・シンプルさを軸としたReact用の状態管理ライブラリです。
contextProviderの再レンダリング問題を解消しつつ、hookベースで気軽に使えるのが特徴的です。
本記事ではzustandで使える状態管理の色々なオプション機能について紹介します。
公式ドキュメントに載っている内容なので、詳しく知りたい方はそちらを見ていただけると🙏

https://zustand.docs.pmnd.rs/getting-started/introduction

Zustandの立ち位置

他の状態管理ライブラリと比較してもここ数年で多くの人気の集めており、古くからある最もメジャーなReduxに迫る勢いを見せています。
https://npmtrends.com/jotai-vs-recoil-vs-redux-vs-zustand
zustandのnpmトレンドのキャプチャ

※2025/11/22時点

前提知識

  • フロントエンドの状態管理の仕組み
  • Typescriptのジェネリクスの仕様(本記事ではTSを使ったZustandの利用について取り扱います)

インストール

# NPM
npm install zustand
# Or, use any package manager of your choice.

基本的な使い方

store用のtsファイルに状態定義と更新用のフックを定義し、フック形式で値と更新メソッドが利用可能になります。

storeの定義

import { create } from "zustand";

type CounterState = {
  count: number;
  increment: () => void;
};

export const useCounter = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

呼び出し側

const count = useCounter((s) => s.count);
const increment = useCounter((s) => s.increment);

ポイント

  • 1ストア=1カスタムフックとして定義する
  • 状態定義はtype または interfaceで定義し、更新メソッドも含める。更新メソッドは基本的にsetメソッドの返り値になるのでvoid型になる。
  • ストア定義はcreate関数が用意されており、Typescriptの場合は、状態の型指定を行う
  • createの引数はアロー関数でsetメソッドが受け取れるのでそのsetに渡すアロー関数として更新メソッドを定義する。

Zustandのミドルウェア

Zustandにはストア作成時の状態管理の挙動を拡張、変更するミドルウェアがあります。
使用方法は簡単でストアを作成するcreateメソッドの引数に関数としてネスト形式に組み合わせることができ、内側から順に適用されます。

create(
 ミドルウェア1(
   ミドルウェア2(
     ミドルウェア3(
        (set) => ({
          count: 0,
          increment: () => set((state) => ({ count: state.count + 1 })),
        })
     )
    )
   )
  )

※ミドルウェア3->ミドルウェア2->ミドルウェア1の順に適用

本記事ではreduxミドルウェアを除く、以下のミドルウェアについて紹介します。

ミドルウェア 機能
immer stateのミュータブル更新を可能にする
combine 状態とアクションの型の整合性を合わせ、型推論を可能にする
persist ローカルストレージやセッションストレージで状態管理する
devtools 状態の変更履歴をRedux DevToolsで確認可能にする
subscribeWithSelector 特定の値の変更に対するsubscribe機能

immerミドルウェアを使ったミュータブル更新

immerを使うとリストやオブジェクトもミュータブルな更新記法で内部的にはイミュータブルな更新が可能になります。
通常の場合、状態更新でリストやオブジェクトを更新する時は、直接更新はせず新規にリストやオブジェクトを作ってイミュータブルに更新する必要があります。

リストの場合

...
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
...

オブジェクトの場合

...
  user: {
    name: "Alice",
    address: {
      city: "Tokyo",
      zip: "100-0000",
      lines: ["1-2-3 Chiyoda"],
    },
  },
  setCity: (city) =>
    set((state) => ({
      user: {
        ...state.user,
        address: {
          ...state.user.address,
          city, // ← ここだけ変更
        },
      },
    })),
...

リストだとまだいいですが、複雑なネストを持つオブジェクトの場合、更新処理が複雑になるので、そのような場合にこのimmerが使えます。
以下のようにimmerでラップして使用することで、更新関数の内部を直接更新の形式で記述することができます。
また、Typescriptの場合、型推論で状態型を知る必要があるため、createメソッドのカリー化関数をコールする必要があります。これを回避するためのミドルウェアとして次に紹介するcombineが定義されています。

export const useTodoStore = create<TodoState>()( // <- ここでカリー化した関数をコールして、コールバック関数の引数にimmerを指定
immer((set)=>({
...
 addItem: (item) => set((state) => {state.items.push(item)}),
...
...
immer((set)=>({
  user: {
    name: "Alice",
    address: {
      city: "Tokyo",
      zip: "100-0000",
      lines: ["1-2-3 Chiyoda"],
    },
  },
  setCity: (city) =>
    set((state) => {
     state.address.city = city 
    }),
...

ストアごとにimmer有無が混在するとそれはそれで管理が混乱してくるので、ある程度ポイントを絞って利用するのが良いかもしれません。

カリー化について余談

zustandのcreateメソッドは以下のように定義されており、typeを使ってカリー化関数を用意することでオーバーロードのような挙動を可能にしています。
この意図はドキュメントでも記載されており、Typescriptの仕様で複数ジェネリクス使用時の呼び出しは全型指定or全型推論のどちらかの形式でしか呼び出せない問題があるため、型の部分適用に対応するためにカリー化関数が用意されています。

https://github.com/microsoft/TypeScript/issues/10571

type Create = {
    <T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(initializer: StateCreator<T, [], Mos>): UseBoundStore<Mutate<StoreApi<T>, Mos>>;
    <T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(initializer: StateCreator<T, [], Mos>) => UseBoundStore<Mutate<StoreApi<T>, Mos>>;
};
export declare const create: Create;

combineミドルウェアで型推論を解決

combineは初期状態の型とアクションの型の整合性を内部的に解決できるミドルウェアでストア定義時の型指定や他のミドルウェア指定時のカリー化関数呼び出しをする必要がなくなります。

combineのみ

export const useCounter = create(
  combine({ count: 0 }, (set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))
);

immerと組み合わせた例(カリー化不要)

export const useBasicStore = create(
  immer(
    combine({ ...initialState }, (set) => ({
      ...initialState,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }))
  )
);

このように記述としては少しだけシンプルに書くことができますが、型を指定しない分、ストア内の型推論は初期状態のオブジェクト情報からのみ判断する必要があり、一部型の安全性が失われてしまう問題があります。
そのような場合には以下のようにcombineを型指定で呼び出すことも可能です。

type State = {
    count: number;
}

type Action = {
    inc: () => void;
}

const useStore = create(
    combine<State,Action>(
        { count: 0 },
        (set) => ({
            inc: () => set((s) => ({ count: s.count + 1 })),
        })
    )
)

ただこの場合、状態型とアクション型の両方を指定する必要があるので、そこまでして利用するべきかはケースバイケースになるかと思います。

persistミドルウェアを使った永続化

状態管理している情報をセッションストレージやローカルストレージで管理したい場合にpersistが使えます。
こちらは第二引数にストレージに関する情報を設定する必要があります。
例えば、あるストア内で一部のデータのみ永続化したい場合は、第二引数のオブジェクトにpartializeプロパティを定義すると対象を絞ることが可能です。

export const useStore = create<State>()(
  persist(
    (set) => ({
      token: null,
      profile: null,
      draftText: '', // 永続化はされない
      tempIds: [],   // 永続化はされない
      setUser: (profile) => set({ profile }),
    }),
    {
      name: 'app-store', // ストレージのキー名
      partialize: (s) => ({
        token: s.token,
        profile: s.profile,
      }),
    }
  )
)

デフォルトではローカルストレージに保存されるため、セッションストレージに保存したい場合は、storageプロパティでセッションストレージを指定する必要があります。

...
{
  name: "session-data",
  storage: createJSONStorage(() => sessionStorage),
  partialize: (s) => ({
        token: s.token,
  }),
}
...

persistの引数に複数のストレージの設定を含めることができないため、一つのストア内でローカルストレージとセッションストレージを混在させることはできず、その場合は別ストアとして定義する必要があります。

デバッグ用のdevtoolsミドルウェア

Redux DevToolsというReduxユーザーにはお馴染みな画面での状態遷移のキャプチャを見ることができるchromeの拡張機能にあります。

この機能をzustandでも扱えるようにしたのがdevtoolsになります。
第二引数にはRedux DevToolに設定するオプションをオブジェクト形式で設定できます(オブジェクト自体の省略も可能)。

const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      inc: () => set((state) => ({ count: state.count + 1 })),
    }),
    {
      name: 'CounterStore',           // DevTools 上でのストア名
      anonymousActionType: 'update',  // 匿名アクションの名前
      ...
    }
  )
)

subscribeWithSelectorミドルウェアによる連動機能

あるストアの値の変更をトリガに別の処理を適用したい場合にこのsubscribeWithSelectorが使えます。

export const useSubscribeStore = create<SubscribeState>()(
  subscribeWithSelector((set) => ({
    temperature: 20,
    humidity: 50,
    alerts: [],
    setTemperature: (temp) => set({ temperature: temp }),
    setHumidity: (humidity) => set({ humidity }),
    addAlert: (alert) => set((state) => ({ alerts: [...state.alerts, alert] })),
    clearAlerts: () => set({ alerts: [] }),
  }))
);

subscribeWithSelectorが適用されたストアはsubscribeメソッドを持つため、ストア経由で第一引数にselectorを、第二引数に連動させたい関数を設定することができます。

useSubscribeStore.subscribe((state) => state.temperature, func(prev: number, next: number){...});

subscribeの定義はUI側、ストア側どちらでも可能ですが、以下のような形でストア側で定義しておくとストアをUI側で直接参照せずに利用することができます。

export const subscribeToTemperature = (callback: (prev: number, next: number) => void) => {
  return useSubscribeStore.subscribe((state) => state.temperature, callback);
};

ミドルウェアの優先度

ミドルウェアは複数組み合わせた場合、内側のミドルウェアから順に評価されるためコール順によって適用内容が変わってきます。
そのため、各ミドルウェアの特性を考慮すると以下の順に外側から内側に定義するのが妥当だと考えられます。

const useBoundStore = create(
  devtools(
    subscribeWithSelector(
      persist(
        immer(
          combine(...)
        ),,
      )
    ),,
  ),
)

こちらは公式で厳密に定義されているわけではないので状況によって前後する可能性はあります。

ミドルウェアの優先順についてはこちらでも議論されています。
https://github.com/pmndrs/zustand/discussions/2389

その他

ここではミドルウェア以外の状態の取り扱いや機能のあれこれについて紹介します。

Map型、Set型の扱い

状態にMap型やSet型を使いたい場合も他の型と同様に使用することができます。

type MapSetState {
  userMap: Map<string, { name: string; age: number }>;
  tagsSet: Set<string>;
}

export const useMapSetStore = create<MapSetState>((set) => ({
  userMap: new Map([
    ["1", { name: "Alice", age: 25 }],
    ["2", { name: "Bob", age: 30 }],
  ]),
  tagsSet: new Set(["javascript", "typescript", "react"]),

  addUser: (id, user) =>
    set((state) => {
      const newMap = new Map(state.userMap);
      newMap.set(id, user);
      return { userMap: newMap };
    }),
  addTag: (tag) =>
    set((state) => {
      const newSet = new Set(state.tagsSet);
      newSet.add(tag);
      return { tagsSet: newSet };
    }),
}))

注意点として、これらの型については値の変更トリガを実態ではなく参照先のアドレスによって検知しているため、immerによる値変更には対応していないので値の変更時は必ずインスタンスを生成する必要があります。

自動生成Selector

zustandではフックを使って利用する場合、以下のように毎回アロー関数を定義して値を取得する必要があります。

const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);

このような手間をなくすための方法として、zustandではstoreに対して外部定義したカスタムselectorの注入が可能なインターフェースになっています。この仕組みを利用してセレクターを定義した例がこちらです。

import type { StoreApi, UseBoundStore } from "zustand";

type WithSelectors<S> = S extends { getState: () => infer T }
  ? S & { use: { [K in keyof T]: () => T[K] } }
  : never;

export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
  const store = _store as WithSelectors<typeof _store>;
  store.use = {} as any;
  for (const k of Object.keys(store.getState())) {
    (store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
  }

  return store;
};

※公式ドキュメントより抜粋

WithSelectorsはstore型に状態が持つすべてのプロパティ(更新用の関数型も含む)のgetterメソッドを提供するuseプロパティを持つ型であり、createSelectorsメソッドにstoreを渡すことでこのuseプロパティに状態を返すgetterを持たせることができます。

このユーティリティ関数を用いて、ストアごとにラップすることで以下のように利用することができます。

export const useCounterStore = createSelectors(useCounterStoreBase);
const count = useCounterStore.use.count();
const increment = useCounterStore.use.increment();

ちょっとした記述の簡素化ですが、useプロパティを参照にすることでIDEによるインテリセンスも効かせられるメリットもあります。

slice pattern

Zustandでは巨大化したストアは分割して管理するスライスパターンが推奨されています。
これはcreateメソッドに設定するアロー関数をスライスとして分割定義して、それらを統合して一つのストアが形成できる仕組みになります。
Typescriptで記述する場合、それぞれのslice用のアロー関数の型定義と一つに統合した際の合成型の二つを満たす必要があります。前者はStateCreator型として用意されており、以下のように書くことができます。

type CounterSlice = {
  count: number;
  inc: () => void;
};

type UiSlice = {
  dark: boolean;
  toggleDark: () => void;
};

type Store = CounterSlice & UiSlice;

// --- slice 実装(型付き) ---
const createCounterSlice: StateCreator<Store, [], [], CounterSlice> = (set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
});

const createUiSlice: StateCreator<Store, [], [], UiSlice> = (set) => ({
  dark: false,
  toggleDark: () => set((s) => ({ dark: !s.dark })),
});

// --- ストア定義 ---
export const useStore = create<Store>()((...a) => ({
  ...createCounterSlice(...a),
  ...createUiSlice(...a),
}));

StateCreator型はStateCreator<T, Mis extends [StoreMutatorIdentifier, unknown][] = [], Mos extends [StoreMutatorIdentifier, unknown][] = [], U = T>として定義されており、4つのジェネリクスタイプを指定する複雑な型となっています。
各ジェネリクスタイプは以下のような構成になっています。

StateCreatorの各引数 意味
第一引数 スライスを保有する全体のストアの型(各スライスの合成型)
第二引数 適用前のミドルウェアの識別情報
第三引数 適用後のミドルウェアの識別情報
第四引数 スライスの型

第二引数、第三引数の違いはミドルウェアを複数指定した場合に、storeが受け取る引数の型は入れ子構造のミドルウェアの型を解釈する必要があり、以下のような状態にも同じ型として表現するためにこのような形になっています。

  • immer,devtoolsが適用されていない状態
  • immerが適用され、devtoolsがまだ適用されていない状態
  • immer,devtoolsが適用された状態

そのため、スライスの宣言定義時には一部のミドルウェアが適用済みである状況を考慮する必要がないため、第三引数は基本的に[]指定になります。
また、この第二引数はミドルウェア関数の識別情報を指定する必要があり、以下のような2要素の配列でそれぞれのミドルウェアに対応づいています。

ミドルウェア 識別情報
immer ['zustand/immer', never]
persist ['zustand/persist', unknown]
devtools ['zustand/devtools', unknown]
subscribeWithSelector ['zustand/subscribeWithSelector', unknown]

そのため、複数のミドルウェアを指定したい場合は二次元配列として表現する必要があり、先頭から後ろにかけて、内側から外側のミドルウェアに対応している仕様になっています。
例えば、storeにpersist,devtoolsの順に二つのミドルウェアを適用したい場合には以下のように書けます。

// --- slice 実装(型付き) ---
const createCounterSlice: StateCreator<
  Store,
  [["zustand/persist", unknown], ["zustand/devtools", unknown]],
  [],
  CounterSlice
> = (set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
});

const createUiSlice: StateCreator<
  Store,
  [["zustand/persist", unknown], ["zustand/devtools", unknown]],
  [],
  UiSlice
> = (set) => ({
  dark: false,
  toggleDark: () => set((s) => ({ dark: !s.dark })),
});

// --- 合成 ---
export const useStore = create<Store>()(
  devtools(
    persist(
      (...a) => ({
        ...createCounterSlice(...a),
        ...createUiSlice(...a),
      }),
      { name: "myapp-storage" }
    )
  )
);

同じストアで管理する各スライスはStateCreatorの第四引数以外は全て同じになるため、以下のようにショートカット型を用意するのが良さそうです。

type SliceCreator<T> = StateCreator<Store,[["zustand/persist", unknown], ["zustand/devtools", unknown]],[],T>;

また、combineを使って以下のようにStateCreator型を省略して書くこともできます。

// --- slice 実装(型付き) ---
const createCounterSlice = combine({ count: 0 }, (set) => ({
  inc: () => set((s) => ({ count: s.count + 1 })),
}));

const createUiSlice = combine({ dark: false }, (set) => ({
  toggleDark: () => set((s) => ({ dark: !s.dark })),
}));

// --- 合成 ---
export const useStore = create<Store>()(
  devtools(
    persist(
      (...a) => ({
        ...createCounterSlice(...a),
        ...createUiSlice(...a),
      }),
      { name: "myapp-storage" }
    )
  )
);

createとcreateStoreの違い

storeを作成するメソッドとしてcreateとは別にcreateStoreが用意されています。

export const counterStore = createStore<State>((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}))

シグネチャはcreateと同様ですが、フックベースでは無いため、Reactに依存せずcounterStore.getState() / counterStore.setState() / counterStore.subscribe() のような形式でストアの操作が可能になります。

storeのテスト

store APIを利用してgetStateメソッドでフックを利用せず、直接状態を取得することが可能で、これを利用してテストすることができます。

it("should increment count", () => {
    const { increment } = useBasicStore.getState();
    expect(useBasicStore.getState().count).toBe(0);
    increment();
    expect(useBasicStore.getState().count).toBe(1);
});

もちろんrenderHook,actを利用したフックとしてもテスト可能です。

it("should increment count", () => {
    const { result } = renderHook(() => useBasicStore());
    expect(result.current.count).toBe(0);
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(1);
});

終わり

今回はZustandの基本から、いくつかのミドルウェアまでを紹介しました。
紹介しきれなかった機能もいくつかあるので、興味のある方は公式ドキュメントをチェックしてみてください。
Zustandを使いこなして、より快適な状態管理ライフを!

Discussion