動機
業務では状態管理にreduxを使うことがほとんどなのですが、正直reduxは面倒くさいことが多いです。
stateを1つ追加するだけでも書かなければならないコードが多く、学習コストもそれなりにかかるため、新しくプロジェクトにジョインしてくれた人に教育をするのも大変です。
何か別の状態管理ライブラリはないものかと探していた時に、Zustandを発見しました。
Zustandとは
Zustandは、React用の軽量な状態管理ライブラリです。開発したのは、CSS-in-JSライブラリ「emotion」や、通知ライブラリ「react-toastify」などで知られる、Poimandresというチームです。
Zustandの特徴は、「少ないコードでシンプルに状態を管理できる」という点です。Reduxと比べると、非常に直感的で、設定にかかる手間がほとんどありません。
公式
使い方
インストール
npm install zustand
もしくは
yarn add zustand
基本的なStoreの構成
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
decrease: () => set((state) => ({ bears: state.bears - 1 })),
}))
Storeはcreate
関数を使って作成します。bears
に加えて、これを変更するアクションも同じ場所で管理されています。
続いてStoreの値を呼び出してみましょう。
function BearCounter() {
const bears = useStore((state) => state.bears)
return <h1>{bears} around here...</h1>
}
このように、簡単に呼び出すことができます。
Zustandによる状態管理の基本構成は以上です。reduxと比べてとても簡潔で、描かなければならないコードが少ないことを感じていただけたのではないでしょうか?
スライスパターン
とはいえ、上記の基本構成では管理したいstateが増えると管理が大変になりそうです。そんな時はスライスパターンを使うことが推奨されています。
スライスパターンを採用することで、大きくなってしまったStoreを個別の小さなStoreに分割することができます。
スライスの書き方は以下の通りです。Storeを作った時と少し書き方が異なるだけで、ほぼ同じです。
fishについてのStore
export const createFishSlice = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
bearについてのStore
export const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})
2つのStoreを合体
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
export const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
stateの呼び出し
import { useBoundStore } from './stores/useBoundStore'
function App() {
// いずれもuseBoundStoreから呼び出すことができている
const bears = useBoundStore((state) => state.bears)
const fishes = useBoundStore((state) => state.fishes)
const addBear = useBoundStore((state) => state.addBear)
return (
<div>
<h2>Number of bears: {bears}</h2>
<h2>Number of fishes: {fishes}</h2>
<button onClick={() => addBear()}>Add a bear</button>
</div>
)
}
export default App
const bears = useBoundStore((state) => state.bears)
のように呼び出すことで、無関係なstateが更新されても再レンダリングが起きないように管理されています。
TypeScript対応
ZustandはTypeScriptにも対応しています。
基本構成の場合
import { create } from 'zustand'
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
create
関数にジェネリクスでstateの型を渡しています。また、型の後に「()」が追加されています。これはカリー化が行われていることを意味します。
ここでカリー化が行われている理由は、型推論の精度をあげるためだそうです。詳しくは公式ページでの解説を参照ください。
スライスパターンの場合
import { create, StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
eatFish: () => void
}
interface FishSlice {
fishes: number
addFish: () => void
}
interface SharedSlice {
addBoth: () => void
getBoth: () => void
}
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})
const createFishSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
FishSlice
> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
const createSharedSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
SharedSlice
> = (set, get) => ({
addBoth: () => {
// すでに定義されているものを使いまわすこともできます
get().addBear()
get().addFish()
// 使いまわさない場合は以下のように書くこともできます
// set((state) => ({ bears: state.bears + 1, fishes: state.fishes + 1 })
},
getBoth: () => get().bears + get().fishes,
})
const useBoundStore = create<BearSlice & FishSlice & SharedSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createSharedSlice(...a),
}))
createBearSlice
, createFishSlice
, createSharedSlice
の型としてStateCreator
が渡されており、StateCreator
にはジェネリクスで4つのものが渡されています。
-
state全体の型
そのスライスが依存している他のスライスの型を渡します。
例えばcreateBearSlice
はbearとfishに関係するstateを扱う必要があるので、BearSlice & FishSlice
が渡されています。もしeatFish()
がなかったら、FishSlice
を渡す必要はありません。 -
ミドルウェアの配列
ミドルウェアを使いたい場合はここに配列を渡すことがあります。使わない場合は空の配列を渡します。
例えばloggerを使いたい場合、[["zustand/devtools", never]]
を渡します。 -
ストアミューテラの配列
ストアの振る舞いをカスタマイズするためのもの。ストアの作成時に追加の機能やロジックを適用したい場合に配列を渡します。
例えばdevtoolsを使いたい場合、[["zustand/devtools", never]]
を渡します。 -
stateとアクションの型
このスライスで作成するstateとアクションの型を渡します。
例えばcreateBearSlice
はbearに関するstateとアクションなので、BearSlice
型が渡されています。
ReduxとZustandの特徴比較
最後にreduxとzustandの特徴比較表を置いておきます。参考になれば幸いです。
特徴 | Zustand | Redux |
---|---|---|
ボイラープレート | 少ない | 多い |
学習コスト | 低い | 高い |
状態管理の規模 | 小規模・中規模向き | 大規模向き |
パフォーマンス | 効率的な再レンダリング | 最適化が必要な場合がある |
ツールサポート | 限定的 | 豊富な公式ツール・DevTools |
参考