Reactで状態管理を行いたい場合は本サイトでも公開済みのRedux、Context APIが最初に思い浮かびますがReact初心者が利用しようと考えた場合になかなか難しいのではないでしょうか。Reduxの利用経験がある人でも設定はどうだったかドキュメントを見たり別のコードを確認したりしているのではないでしょうか。

Redux以外にもさまざまな状態管理のライブラリが提供されていますが本文書で紹介するZustandは設定がシンプルなので初心者の人でも簡単に利用することができます。特にReduxの設定経験がある人であれば間違いなく簡単だと実感することができます。何回か使えば初期設定も暗記できるぐらいコード量が少ないです。

Reduxなどの状態管理ライブラリを利用しない場合はコンポーネント間でのデータの受け渡しはpropsを使って行わなければなりません。状態管理ライブラリを利用することでアプリケーションを構成するすべてのコンポーネントからアクセスすることができる変数を設定することができpropsを利用することなくデータを共有することができます。

本文書ではシンプルなコードを通してZustandの設定方法と基本機能を説明しています。

Reactの環境構築

npx create-react-appコマンドでReactのプロジェクトを作成を行います。プロジェクト名には任意の名前をつけてください。


 % npx create-react-app zustand-test

Zustandのインストール

インストールはnpmコマンドを利用して行います。


 % npm install zustand

Counterで動作確認

ボタンをクリックするとカウントの数が更新されるシンプルなカウンター機能を利用してZustandの動作確認を行なっていきます。

Storeの作成

最初にすべてのコンポーネントからアクセスできる場所(store)を作成します。srcフォルダにstore.jsファイルを作成して以下のコードを記述します。


import create from 'zustand';

const useStore = create((set) => ({
  count: 1,
  increase: () => set((state) => ({ count: state.count + 1 })),
  decrease: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

export default useStore;

zustandからcreate関数をimportします。create関数の中ではcallback関数を利用して共有したい変数の初期値と関数を設定します。ここではcountという変数に1という初期値を設定しています。関数を設定する場合はset関数を利用することで変数を更新することが可能となります。create関数の戻り値の関数をuseStoreに保存しexportすることででcreate関数内で定義した変数や関数にアクセスすることができます。

またset関数だけでなくget関数も利用することができget関数を使って以下のようにcountにアクセスすることも可能です。


import create from 'zustand';

const useStore = create((set, get) => ({
  count: 1,
  increase: () => set({ count: get().count + 1 }),
  decrease: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

export default useStore;

countへのアクセス

すべてのコンポーネントからアクセス可能な場所(store)が設定できたら、Appコンポーネントからcountへアクセスを行うことができるか確認を行います。useStoreをimportします。useStoreからcountを取り出すことができます。


import useStore from './store';
function App() {
  const { count } = useStore();
  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>Count</h1>
      <div>{count}</div>
    </div>
  );
}

export default App;

store.jsで初期値に設定した1が表示されればZustandが正常に設定されていることがわかります。

countを表示
countを表示

create関数のcountの初期値を変更すると表示されるcountの数が変わることも確認してください。

Appコンポーネントだけではなく他のコンポーネントからもアクセスできるか確認するためにcomponentsフォルダを作成しCount.jsファイルを作成します。


import useStore from '../store';
const Count = () => {
  const count = useStore((state) => state.count);
  return <div>{count}</div>;
};

export default Count;

countの取り出し方が先程と変わっていますがuseStoreではselectorでcountの値のみ取得することができます。後ほどselectorを利用する場合と分割代入 (Destructuring assignment)でどのような違いがあるか説明します。

Appコンポーネントで作成したCountコンポーネントをimportします。


import useStore from './store';
import Count from './components/Count';
function App() {
  const { count } = useStore();
  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>Count</h1>
      <div>{count}</div>
      <Count />
    </div>
  );
}

export default App;

App、Countコンポーネントの両方からcountにアクセスできることがわかります。これですべてのコンポーネントからアクセスすることができるということもわかりました。

2つのコンポーネントでの表示
2つのコンポーネントでの表示

関数によるcountの更新

変数countへのアクセス方法は理解できたので次はcreate関数で定義したincrease, decrease関数を利用してcountを更新できるか確認するためにApp.jsを更新します。関数をuseStoreから取り出す方法はCount.jsのcountの時と同じでselectorで取得する関数を指定しています。


import useStore from './store';
import Count from './components/Count';
function App() {
  const { count } = useStore();
  const increase = useStore((state) => state.increase);
  const decrease = useStore((state) => state.decrease);

  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>Count</h1>
      <div>{count}</div>
      <Count />
      <div>
        <button onClick={() => increase()}>+</button>
        <button onClick={() => decrease()}>-</button>
      </div>
    </div>
  );
}

export default App;

ブラウザには”+”と”-“ボタンが表示されるのでボタンをクリックするとcountの数が変わることを確認してください。countを更新することができればアクセスだけでななく更新も可能であることがわかります。

関数を利用してcountを更新
関数を利用してcountを更新

selectorを利用せず分割代入を利用しても動作に変わりはありません。


const { increase, decrease } = useStore();

count更新時の再描写について

selectorと分割代入のどちらを利用してもカウンターは動作することがわかったので違いを確認します。selectorを利用するとcountを更新した時に行われるコンポーネントの再描写を抑えることができます。

まずは分割代入を使って動作確認を行います。再描写を確認するためにconsole.logを利用します。Appコンポーネントからcountを削除しています。


import useStore from './store';
import Count from './components/Count';
function App() {
  console.log('再描写');
  const { increase, decrease } = useStore();

  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>Count</h1>
      <Count />
      <div>
        <button onClick={() => increase()}>+</button>
        <button onClick={() => decrease()}>-</button>
      </div>
    </div>
  );
}

export default App;

1回目の再描写はアクセス時に必ず表示されるので無視してください。+ボタンを4回押すと再描写が4回表示されます。つまりAppコンポーネントはcountの更新と一緒に4回再描写されています。

再描写の確認
再描写の確認

次にselectorを利用した場合で動作確認を行います。


console.log('再描写');
const increase = useStore((state) => state.increase);
const decrease = useStore((state) => state.decrease);
// const { increase, decrease } = useStore();

先程とは異なりアクセス時の再描写は一度表示されますがその後ボタンを押してもAppコンポーネントが再描写されることはなくなりました。

selectorを利用した場合
selectorを利用した場合

この動作については公式ドキュメントFetching everythingに説明されています。”You can, but bear in mind that it will cause the component to update on every state change!”の意味が実際の動作で理解できました。


const state = useStore()

increase, decreaseを1行毎に取得していましたが下記のように記述することで一度に複数の関数を取得することができます。


import useStore from './store';
import Count from './components/Count';
import shallow from 'zustand/shallow';
function App() {
  console.log('再描写');
  const { increase, decrease } = useStore(
    (state) => ({
      increase: state.increase,
      decrease: state.decrease,
    }),
    shallow
  );

shallowをimportしていますがshallowがなくても動作しますがその場合はcountを更新するとコンポーネントの再描写が行われます。

reset関数の設定

Appコンポーネント以外からもcountを更新できるか確認するためにcomponentsフォルダを作成しReset.jsファイルを作成します。


import useStore from '../store';
const Reset = () => {
  const reset = useStore((state) => state.reset);
  return (
    <div>
      <button onClick={() => reset()}>Reset</button>
    </div>
  );
};

export default Reset;

App.jsファイルでReset.jsファイルをimportします。


import useStore from './store';
import Count from './components/Count';
import Reset from './components/Reset';
import shallow from 'zustand/shallow';
function App() {
    (state) => ({
      increase: state.increase,
      decrease: state.decrease,
    }),
    shallow
  );

  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>Count</h1>
      <Count />
      <div>
        <button onClick={() => increase()}>+</button>
        <button onClick={() => decrease()}>-</button>
      </div>
      <Reset />
    </div>
  );
}

export default App;

“+”、”-“ボタンでcountの数が更新されることを確認して”Reset”ボタンをクリックしてください。0になれば他のコンポーネントからもcountが更新できることがわかります。

reset関数の動作確認
reset関数の動作確認

Counterという非常にシンプルなコードですが、Zustandの設定が非常にシンプルで簡単だということが理解できたかと思います。

Redux devtoolsの利用

ZustandではReduxのdevtoolsを利用することができます。設定はcreate関数で指定したcallback関数をdevtoolsで包みます。devtoolsはzustand/middlewareからimportします。


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

const useStore = create(
  devtools((set) => ({
    count: 1,
    increase: () => set((state) => ({ count: state.count + 1 })),
    decrease: () => set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
  }))
);

export default useStore;

これだけで設定は完了です。ブラウザのRedux devtoolsを開くとcountの情報も確認することができます。

devtoolsからzustandの状態確認
devtoolsからzustandの状態確認

ページリロード後の値の保持

現在の設定ではページをリロードするとcountの数字はリセットされてしまいますがページのリロード後のcountの値を保持したい場合はmiddlwareのpersistを利用することができます。devtoolsと同様にcreateのcallback関数をpersistで包むだけです。


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

const useStore = create(
  persist((set) => ({
    count: 1,
    increase: () => set((state) => ({ count: state.count + 1 })),
    decrease: () => set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
  }))
);

export default useStore;

ボタンによってcountの値を更新した後、ブラウザのリロードを行いcountの値が保持できていることを確認してください。countの値はlocal Storageに保存されるのでデベロッパーツールのApplicationタブからcountの現在の値を確認することができます。

persisteによるcountの値の保持
persisteによるcountの値の保持

外部リソースからのデータの取得

実際にアプリケーションを構築する際は初期値は外部のサーバから取得する機会もあるかと思います。JSON PlaceHolderから実際にデータを取得しブラウザ上にユーザ情報を表示する方法を確認します。

ユーザ情報を取得するURLはhttps://jsonplaceholder.typicode.com/usersを使います。

store.jsを下記のように更新します。getUsers関数を追加しaysnc, await, fecth関数を利用してユーザの情報を取得しています。Zustandではcreateの関数の中でasyncを利用することができます。


import create from 'zustand';

const useStore = create((set) => ({
  users: [],
  getUsers: async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    set({ users: await response.json() });
  },
}));

export default useStore;

AppコンポーネントではReact HookのuseEffect内でgetUsersを実行してユーザ一覧を取得しmap関数でusesを展開して表示しています。


import { useEffect } from 'react';
import useStore from './store';
function App() {
  const getUsers = useStore((state) => state.getUsers);
  const users = useStore((state) => state.users);

  useEffect(() => {
    getUsers();
  }, [getUsers]);

  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>User</h1>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

export default App;

ブラウザ上にはユーザ一覧が表示されます。Zustandを利用して外部リソースから情報を取得することができました。

ユーザ一覧の表示
ユーザ一覧の表示

状態の変化の検知

Zustandでは設定したstateの状態の変化を検知する機能も備えています。

新たにdeleteUser関数を追加して、取得したユーザ一覧からユーザ情報を削除する機能を追加します。


const useStore = create((set, get) => ({
  users: [],
  getUsers: async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    set({ users: await response.json() });
  },
  deleteUser: (id) =>
    set((state) => ({ users: state.users.filter((user) => user.id !== id) })),
}));

名前の横に”X”ボタンを追加し、クリックするとdeleteUser関数が実行されるようにApp.jsファイルを更新します。


import { useEffect } from 'react';
import useStore from './store';
function App() {
  const getUsers = useStore((state) => state.getUsers);
  const deleteUser = useStore((state) => state.deleteUser);
  const users = useStore((state) => state.users);

  useEffect(() => {
    getUsers();
  }, [getUsers]);

  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <h1>User</h1>
      {users.map((user) => (
        <div key={user.id}>
          {user.name}
          <span onClick={() => deleteUser(user.id)}>X</span>
        </div>
      ))}
    </div>
  );
}

export default App;

ブラウザで確認すると”X”ボタンが表示され、クリックするとクリックしたユーザが削除されます。

ユーザの削除
ユーザの削除

ユーザの削除を他のコンポーネントから検知できるように新たにcomponetsフォルダにSubscribe.jsファイルを作成します。useStoreをimportし、subscribeメソッドにconsole.logを指定します。useEffect内で検知を開始しアンマウント時に削除できようにunsub1のクリーンアップを行なっています。


import { useEffect } from 'react';
import useStore from '../store';

const Subscribe = () => {
  const unsub1 = useEffect(() => {
    useStore.subscribe(console.log);
    return () => {
      unsub1();
    };
  }, []);
  return <div>subscribe</div>;
};

export default Subscribe;

App.jsファイルで作成したSubscribeコンポーネントをimportします。


import { useEffect } from 'react';
import useStore from './store';
import Subscribe from './components/Subscribe';
function App() {
  const getUsers = useStore((state) => state.getUsers);
  const deleteUser = useStore((state) => state.deleteUser);
  const users = useStore((state) => state.users);

  useEffect(() => {
    getUsers();
  }, [getUsers]);

  return (
    <div style={{ textAlign: 'center', margin: '1em' }}>
      <Subscribe />
      <h1>User</h1>
      {users.map((user) => (
        <div key={user.id}>
          {user.name}
          <span onClick={() => deleteUser(user.id)}>X</span>
        </div>
      ))}
    </div>
  );
}

export default App;

削除ボタンを押すとブラウザのコンソールにcreate関数で定義した変数、関数すべてが表示されることが確認できます。Subscribeコンポーネントで設定したsubscribe関数が更新を検知しています。

usersの変化を検知することが可能
usersの変化を検知することが可能

すべてのstateの情報が表示されましたが、ユーザ情報のみ取得したい場合は下記のように記述することでusersだけ取り出すことができます。


const unsub1 = useStore.subscribe(console.log, (state) => state.users);
usersのみの情報の変化を検知
usersのみの情報の変化を検知

Zustandが持つすべての機能の説明を行えたわけではありませんがReduxに比較してシンプルでミドルウェアによって機能の拡張も行えることを理解してもらえたかと思います。

Zustandを状態管理の選択肢に加えてみてはどうでしょうか。