zustandを触る

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

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

インストール

shell
yarn add zustand

zustandは、デフォルトでTypeScriptをサポートしています。

基本

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の外側で使用することもできます。

とりあえずHTML + TypeScriptでやりました。
内部的にはReactのHooksでやっているので、React自体は必要です。
(下記は、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 });
});