zustandを触る

JavaScript, React

Flux ライクなステート管理ライブラリの zustand を触った。
https://github.com/react-spring/zustand

サンプルはこちら
https://github.com/grgr-dkrk/zustand-sample

インストール

shell

npm install zustand

基本

create, set

create を用いて、useStore Hook を作成する。
createの第一引数には set が入り、こちらで state の更新をする。

src/store.ts

import create from 'zustand';

type AppState = {
  count: number;
  increase: () => void;
  reset: () => void;
 };

export const [useStore] = create<AppState>(set => ({
  count: 0,
  increase: () => set(state => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));

async/await も使える。

src/store.ts

export const [useStore] = create<AppState>(set => ({
  count: 0,
  increase: () => set(state => ({ count: state.count + 1 })),
  // async / await
  reset: async () => {
    const resetCount = await Promise.resolve(0);
    set({ count: resetCount });
  },
}));

コンポーネントで、useStore を読み込む。

src/components/DisplayCount.tsx

import * as React from 'react';
import { useStore } from '~/store';

const DisplayCount: React.FC = () => {
  const count = useStore(state => state.count);
  return <p aria-live="polite">{count}</p>;
};

export default DisplayCount;

src/components/CountControll.tsx

import * as React from 'react';
import { useStore } from '~/store';

const CountControll: React.FC = () => {
  const controll = useStore();
  return (
    <React.Fragment>
      <button onClick={controll.increase}>カウントを1増やす</button>
      <button onClick={controll.reset}>リセット</button>
    </React.Fragment>
  );
};

export default CountControll;

src/app.tsx

import * as React from 'react';
import DisplayCount from './components/DisplayCount';
import CountControll from './components/CountControl';

export const App = () => {
  return (
    <React.Fragment>
      <DisplayCount />
      <CountControll />
    </React.Fragment>
  );
};

zustandで作ったカウンター

Shallow Equal は、shallow を第三引数に指定する。

src/components/DisplayPersonData.tsx

import * as React from 'react';
import shallow from 'zustand/shallow';
import { usePersonStore } from '~/store';

const DisplayPersonData: React.FC = () => {
  const { name, age } = usePersonStore(
    state => ({ name: state.name, age: state.name }),
    shallow
  );

  return <p>{name}{age}</p>;
};

複数のStore

単一ではなく、複数の Store を作れる。

src/store.ts

import create from 'zustand';

type UseCredentialsState = {
  currentUser: string;
};

type UsePersonState = {
  persons: {
    [key: string]: string;
  };
};

export const [useCredentialsStore] = create<UseCredentialsState>(set => ({
  currentUser: 'jiro',
}));

export const [usePersonStore] = create<UsePersonState>(set => ({
  persons: {
    taro: '太郎',
    jiro: '二郎',
    saburo: '三朗',
  },
}));

src/DisplayUser.tsx

import * as React from 'react';
import { useCredentialsStore, usePersonStore } from '~/store';

const DisplayUser: React.FC = () => {
  const currentUser = useCredentialsStore(state => state.currentUser);
  const person = usePersonStore(state => state.persons[currentUser]);

  // usePersonStoreのpersons['jiro']を表示
  return <p aria-live="polite">{person}</p>;
};

export default DisplayUser;

Redux

zustand は Middleware を備えている。
標準では redux Middleware が搭載されていて、Redux 風に書ける。

まず、FSA な reducer(state と action を含む)を書く。

src/reducers/Counter.ts

/**
 * State
 */
type CounterState = {
  count: number;
};

export const initialState: CounterState = {
  count: 0,
};

/**
 * Action Types
 */
const ADD_COUNT = 'ADD_COUNT' as const;
const RESET_COUNT = 'RESET_COUNT' as const;

/**
 * Actions
 */
export const addCount = (payload: CounterState['count']) => ({
  type: ADD_COUNT,
  payload,
});

export const resetCount = () => ({
  type: RESET_COUNT,
});

type ActionsType = ReturnType<typeof addCount> | ReturnType<typeof resetCount>;

/**
 * Reducer
 */
export const CounterReducer = (
  state = initialState,
  action: ActionsType
): CounterState => {
  switch (action.type) {
    case ADD_COUNT:
      return {
        ...state,
        count: state.count + action.payload,
      };
    case RESET_COUNT:
      return {
        ...state,
        count: 0,
      };
    default:
      const _: never = action;
      return state;
  }
};

store では、create 関数の仮引数に redux Middleware を指定する。

src/store.ts

import create from 'zustand';
import { redux } from 'zustand/middleware';
import {
  CounterReducer,
  initialState,
  CounterState,
  ActionsType as CounterActionsType,
} from './reducers/Counter';

export const [useCounterStore] = create<
  CounterState & { dispatch: (action: CounterActionsType) => void }
>(redux(CounterReducer, initialState));

先ほどまでは、create のジェネリクスに State の型を指定していた。
しかし redux Middleware を挟んだ create の戻り値には、State だけでなく dispatch も含まれる。
そのため上の例では dispatch の型を気持ちだけ追加している。

Component では、作成した useCounterStore の dispatch を使う。

src/components/CounterControll.tsx

import * as React from 'react';
import { useCounterStore } from '~/store';
import { addCount, resetCount } from '~/reducers/Counter';

const CounterControll: React.FC = () => {
  const { dispatch } = useCounterStore();
  return (
    <React.Fragment>
      <button onClick={() => dispatch(addCount(1))}>カウントを1増やす</button>
      <button onClick={() => dispatch(resetCount())}>リセット</button>
    </React.Fragment>
  );
};

export default CounterControll;

devtools Middlware を噛ませることで、Redux DevTools が使用できる。

src/store.ts

import { redux, devtools } from 'zustand/middleware';
import {
  CounterReducer,
  initialState,
  CounterState,
  ActionsType as CounterActionsType,
} from './reducers/Counter';

export const [useCounterStore] = create<
  CounterState & { dispatch: (action: CounterActionsType) => void }
>(devtools(redux(CounterReducer, initialState)));

これで、Redux のように書けることがわかった。

subscribe

subscribe を自前で用意し、React の外側で使用できる。 しかし内部的には React の Hooks でやっていて、React 自体は必要だった。

とりあえず HTML + TypeScript + Parcel でやった。

index.html

<!DOCTYPE html>
<html lang="ja">
<body id="body">
  <p id="displayCount"></p>
  <button id="addButton">カウントを1増やす</button>
  <button id="resetButton">リセットする</button>
</body>
</html>

index.ts

import create from 'zustand';

const displayCount = document.getElementById('displayCount')!;
const addButton = document.getElementById('addButton')!;
const resetButton = document.getElementById('resetButton')!;

type AppState = {
  count: number;
};

export const [_, api] = create<AppState>(set => ({
  count: 0,
}));

displayCount.textContent = '0';

const unSubscribeCount = api.subscribe<AppState['count']>(
  count => {
    displayCount.textContent = count + '';
  },
  state => state.count
);

addButton.addEventListener('click', () => {
  api.setState({ count: api.getState().count + 1 });
});

resetButton.addEventListener('click', () => {
  api.setState({ count: 0 });
});