状態管理:Zustand / Redux
トムのショッピングカート機能は当初、
useStateとpropsを使用してデータを層ごとに渡すことで実装されていましたが、かろうじて機能する程度でした。しかし、コンポーネントツリーが深くなるにつれて(App → Header → CartIcon、App → ProductList → ProductCard → AddToCartButton)、ショッピングカートの状態を変更するには、5層ものコンポーネントを介してコールバック関数を渡す必要があり、ほんのわずかなミスでもコールバックが見落とされたり、誤って渡されたりしてしまう可能性がありました。彼は次のように気づきました:どのコンポーネントでも共有状態を直接読み書きできる、グローバルな状態管理ソリューションが必要だ。
1. 学習内容
- Zustand: ミニマリストな状態管理のためのコアAPI
- Redux Toolkit のワークフロー:Slice → Store → Provider → Hooks
- 2つのソリューションの選定基準と適用シナリオ
- 永続ストレージミドルウェアの使用
- TypeScriptにおける型安全な状態管理
2. 概念図
flowchart LR
subgraph Zustand[Zustand Pattern]
A1[create API] --> B1[Store<br/>state + actions]
B1 --> C1[Component A<br/>Subscribe Now]
B1 --> C2[Component B<br/>Subscribe Now]
B1 --> C3[Component C<br/>Subscribe Now]
end
subgraph Redux[Redux Toolkit Pattern]
A2[createSlice] --> B2[Slice<br/>reducers + actions]
B2 --> C4[configureStore]
C4 --> D[Provider<br/>Wrapper Root Component]
D --> E1[useSelector<br/>Read Status]
D --> E2[useDispatch<br/>Trigger an update]
end
style A1 fill:#e1f5fe,stroke:#0288d1
style B1 fill:#fff3e0,stroke:#f57c00
style C4 fill:#e8f5e9,stroke:#388e3c
style D fill:#c8e6c9,stroke:#2e7d32
左:Zustand—プロバイダーなし、直接サブスクリプション、そして最小限のAPI。右:Redux Toolkit—完全なワークフローを提供し、大規模なチームでの共同作業に適している。
3. 実際の事例
トムのショッピングカートには、いくつかの機能が必要です。具体的には、商品一覧ページにある「カートに入れる」ボタン、ヘッダーにあるショッピングカートのアイコン(商品数を表示するもの)、ショッピングカートページ(商品一覧と合計金額を表示するもの)、そして注文後にショッピングカートを空にする機能です。これらのコンポーネントはコンポーネントツリーの異なるレベルに分散しており、useState + props を使用して渡すのは手間がかかるだけでなく、エラーも発生しやすくなります。
(1) なぜContextを代用できないのですか?
ReactのContextは、確かにプロップが複数のレベルにわたって伝播されるのを防ぎますが、これには2つの重大な問題があります:
- パフォーマンスの問題: コンテキストが更新されると、そのコンテキストを使用しているすべてのコンポーネントは、たとえ読み取り専用であっても再レンダリングされます。ショッピングカートの例で言えば、数量が変更された際、ショッピングカートのアイコンも再レンダリングされます。
- 組み込みのアクションメカニズムがない: コンテキストは、値を渡すための標準化された方法を提供するのみであり、状態を更新するための標準化された方法は提供しないため、更新ロジックがコード全体に散らばってしまうことが容易に起こり得る。
専用の状態管理ライブラリ(Zustand、Redux Toolkit)は、選択的サブスクリプションの仕組みを通じてパフォーマンスの問題に対処しています。コンポーネントは、自身に関係する状態の断片のみを監視し、関係のない更新によって再レンダリングがトリガーされることはありません。
| ソリューション | パフォーマンス | プロバイダー | 選択的サブスクリプション | 習得の難易度 | パッケージサイズ |
|---|---|---|---|---|---|
| useState + props | ✅ ローカルでは高速 | なし | 該当なし | 極めて低い | 0 |
| コンテキスト + useReducer | ❌ 完全な再レンダリング | 必須 | ❌ | 低 | 0 |
| ステータス | ✅ オプションのサブスクリプション | 不要 | ✅ ネイティブ | 低 | 約1KB |
| Redux Toolkit | ✅ useSelector | 必須 | ✅ | in | 約12KB |
(2) 状態:ミニマリスト的アプローチ
Zustandの根底にある考え方は、1つのフックですべてを処理するというものです:create() これにより、状態とアクションを同時に定義でき、返されたフックはProviderでラップする必要なく、どのコンポーネントでも直接使用できます。
npm install zustand
(1) ▶ サンプル:Zustand ショッピングカートストア
import { create } from 'zustand'
// Definition store Types of(TypeScript Project Recommendations)
/*
type CartItem = { id: number; name: string; price: number; qty: number }
type CartStore = {
items: CartItem[]
total: number
addItem: (product: Omit<CartItem, 'qty'>) => void
removeItem: (id: number) => void
updateQty: (id: number, qty: number) => void
clearCart: () => void
}
*/
// Create store — state and actions They're all defined here
const useCartStore = create((set, get) => ({
// --- state ---
items: [],
total: 0,
// --- actions ---
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
const newItems = existing
? state.items.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...product, qty: 1 }]
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
removeItem: (id) => set((state) => {
const newItems = state.items.filter(i => i.id !== id)
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
updateQty: (id, qty) => set((state) => {
const newItems = qty <= 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, qty } : i)
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
clearCart: () => set({ items: [], total: 0 })
}))
// Use in any component
function CartIcon() {
// Subscribe to items.length only,The page will only be re-rendered when the shopping cart list changes.
const itemCount = useCartStore(state => state.items.length)
return <span className="cart-badge">Shopping Cart ({itemCount})</span>
}
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem)
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
)
}
function CartPage() {
const { items, total, updateQty, removeItem, clearCart } = useCartStore()
if (items.length === 0) return <p>Your shopping cart is empty</p>
return (
<div>
<h2>Shopping Cart</h2>
{items.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<input
type="number"
value={item.qty}
min="0"
onChange={e => updateQty(item.id, Number(e.target.value))}
/>
<span>${item.price * item.qty}</span>
<button onClick={() => removeItem(item.id)}>Delete</button>
</div>
))}
<hr />
<p><strong>Total:${total}</strong></p>
<button onClick={clearCart}>Empty Cart</button>
</div>
)
}
Zustandの主な特徴:
- Zero Provider:
<Provider>をラップする必要はありません。任意のコンポーネント内でフックを直接呼び出すだけで済みます - 選択的な子要素のサブスクリプション:
useCartStore(state => state.items.length)は、items.lengthが変更された場合にのみ再レンダリングをトリガーするため、Context よりも優れたパフォーマンスを発揮します - set + get:
setは状態を更新し、getは現在の状態を読み取ります(アクション内で他の状態値を読み取る際に便利です)。
(2) ▶ サンプル:Zustand パーシステンス・ミドルウェア
トムは、ユーザーがショッピングカートのデータを失うことなくページを更新できるようにしたいと考えています。Zustandのpersistミドルウェアは、ストアのデータをlocalStorageに自動的に同期させることができます。
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// persist Middleware Package store Definition
const useCartStore = create(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
const newItems = existing
? state.items.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...product, qty: 1 }]
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
removeItem: (id) => set((state) => {
const newItems = state.items.filter(i => i.id !== id)
return {
items: newItems,
total: newItems.reduce((sum, i) => sum + i.price * i.qty, 0)
}
}),
clearCart: () => set({ items: [], total: 0 })
}),
{
name: 'cart-storage', // localStorage 's key
// Store all fields by default,That's fine, too. partialize Select which fields to store
// partialize: (state) => ({ items: state.items }),
}
)
)
// The way it works remains exactly the same
function CartIcon() {
const itemCount = useCartStore(state => state.items.length)
return <span>Shopping Cart ({itemCount})</span>
}
// localStorage Data Format Stored in the System:
// key: 'cart-storage'
// value: '{"state":{"items":[...],"total":99},"version":0}'
Persistenceミドルウェアの仕組み: 状態の変更はすべて自動的にlocalStorageに同期され、アプリケーションの起動時にはlocalStorageからデータが自動的に復元されます。ユーザーがページを更新しても、ショッピングカートの状態は完全に保持されます。
(3) ▶ サンプル:Zustand DevTools でのデバッグ
開発段階において、トムはRedux DevToolsと同様に、状態の変化履歴を確認したいと考えていました。Zustandのdevtoolsミドルウェアは、ブラウザのDevTools拡張機能とシームレスに連携します。
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
// Using Multiple Middleware Components Simultaneously:Combine using a nested approach
const useStore = create(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
}),
{ name: 'app-storage' }
),
{ name: 'AppStore' } // DevTools As shown in store Name
)
)
// Open in a browser Redux DevTools Expand
// As you can see "AppStore" All items under this tag state Change Log
// Supports time-travel debugging(Jump to any historical state)
Zustand ミドルウェアシステムの特徴: ミドルウェアは、ネストされた関数の組み合わせを通じて、外層から内層へと順次実行されます。devtools(persist(...)) は、まず DevTools が接続され、その後に永続化処理が行われることを示しています。複数のミドルウェアコンポーネントを柔軟に組み合わせることで、さまざまなニーズに対応できます。例えば、immer ミドルウェアを追加して変数構文を有効にしたり、各アクションをログに記録するカスタムミドルウェアを作成したりすることができます。
4. Redux Toolkit:エンタープライズグレードのソリューション
プロジェクトが拡大し、複数のチームが連携するようになると、Zustandの柔軟性がかえって問題となる場合があります。開発者によってアクションの記述スタイルが統一されなくなる可能性があるからです。Redux Toolkitは、チーム全体で一貫したパターンを徹底させる標準化された状態管理ワークフローを提供します。
npm install @reduxjs/toolkit react-redux
(1) ▶ サンプル:Redux Toolkit のワークフロー全体
import { createSlice, configureStore } from '@reduxjs/toolkit'
import { Provider, useSelector, useDispatch } from 'react-redux'
// ========== 1. Create Slice(Definition state + reducers)==========
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0, history: [] },
reducers: {
increment: (state) => {
state.value += 1
state.history.push(`+1 → ${state.value}`)
},
decrement: (state) => {
state.value -= 1
state.history.push(`-1 → ${state.value}`)
},
incrementByAmount: (state, action) => {
state.value += action.payload
state.history.push(`+${action.payload} → ${state.value}`)
},
reset: (state) => {
state.value = 0
state.history = []
}
}
})
// Automatically generated action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
// ========== 2. Layout Store ==========
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
// More can be added later. slice:
// cart: cartSlice.reducer,
// user: userSlice.reducer,
}
})
// ========== 3. Provider Inject ==========
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
)
}
// ========== 4. Used in components ==========
function Counter() {
// Selective Reading of Characters
const value = useSelector(state => state.counter.value)
const history = useSelector(state => state.counter.history)
const dispatch = useDispatch()
return (
<div>
<h2>Counter:{value}</h2>
<div>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
<button onClick={() => dispatch(reset())}>Reset</button>
</div>
<div>
<h3>Transaction History</h3>
<ul>
{history.slice(-5).map((h, i) => (
<li key={i}>{h}</li>
))}
</ul>
</div>
</div>
)
}
Redux Toolkitの主要な概念:
| 概念 | 機能 | 類推 |
|---|---|---|
createSlice |
関連するステートとリデューサーのセットを作成する | ファイル引き出し:整理と保管 |
configureStore |
すべてのスライスを統合してグローバルストアを作成する | ファイルキャビネット:一元管理 |
Provider |
Reactコンポーネントツリーへのストアの注入 | 電源コード:差し込む |
useSelector |
ストアから状態フラグメントを読み込む | 引き出しを開いてファイルを取り出す |
useDispatch |
ステータスを更新するアクションをトリガーする | 管理者にアクションを実行するよう指示する |
(2) ▶ サンプル:Redux Toolkit における非同期処理
実際のプロジェクトにおける状態の更新には、多くの場合、非同期ロジック(ログインやユーザー情報の取得など)が関わってきます。Redux Toolkit には、非同期操作のライフサイクル段階(pending、fulfilled、rejected)に対するサポートが組み込まれています。
import { createSlice, configureStore, createAsyncThunk } from '@reduxjs/toolkit'
import { Provider, useSelector, useDispatch } from 'react-redux'
// 1. Defining Asynchronous thunk(Automatically Generated pending/fulfilled/rejected Three types action)
const fetchUser = createAsyncThunk(
'user/fetchUser', // action Type prefix
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to retrieve the user')
return await response.json()
} catch (err) {
return rejectWithValue(err.message)
}
}
)
// 2. Create slice
const userSlice = createSlice({
name: 'user',
initialState: {
data: null,
loading: false,
error: null
},
reducers: {
clearUser: (state) => {
state.data = null
state.error = null
}
},
// Handling Asynchronous Operations thunk The Three States of
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
state.error = action.payload
})
}
})
export const { clearUser } = userSlice.actions
// 3. Layout store
const store = configureStore({
reducer: { user: userSlice.reducer }
})
// 4. Used in components
function UserProfile({ userId }) {
const dispatch = useDispatch()
const { data, loading, error } = useSelector(state => state.user)
return (
<div>
<button onClick={() => dispatch(fetchUser(userId))}>Load Users</button>
{loading && <p>Loading......</p>}
{error && <p>Error:{error}</p>}
{data && <p>{data.name} — {data.email}</p>}
</div>
)
}
createAsyncThunk は、以下の 3 つの状態を自動的に処理します: リクエストが開始されると、pending をディスパッチします → 成功時には、fulfilled(データを含む)をディスパッチします → 失敗時には、rejected(エラーメッセージを含む)をディスパッチします。開発者は、extraReducers 内で各条件に応じて状態がどのように変化するかを定義するだけで済みます。useEffect や fetch を直接使用する場合と比較して、このパターンの利点としては、リクエストステータスの自動追跡、DevTools によるアクションのログ記録、および複数のコンポーネントが同じサンクの結果にサブスクライブできることが挙げられます。
5. モデルの比較
| ディメンション | 状態 | Redux Toolkit |
|---|---|---|
| コード量 | ごくわずか—create()関数が1つだけ |
適度—slice、store、Providerの全機能 |
| 習得の難易度 | 低(10分で始められる) | 中(リデューサー、アクション、ディスパッチ、サンクについての理解が必要) |
| プロバイダー | 不要 | プロバイダーパッケージである必要がある |
| 選択的サブスクリプション | ネイティブサポート、セレクタ関数 | useSelector + 浅い比較 |
| TypeScript | ネイティブ対応 | 良好(型推論あり) |
| ミドルウェア | persist / devtools / immer など | 組み込みのサンク + 拡張可能なサガ/エピック |
| 非同期処理 | アクション内で手動で実装 | createAsyncThunk に組み込み済み |
| パッケージサイズ | 約1KB | 約12KB |
| ユースケース | 中小規模のアプリケーション、コンポーネント間の状態共有 | 大規模なアプリケーション、複雑なデータフロー、チーム間の連携 |
| DevTools | 対応済み(ミドルウェアが必要) | ネイティブ対応、タイムトラベルデバッグ |
選び方のポイント:
- 小規模なプロジェクト/プロトタイプ/個人的なプロジェクト → Zustand。コード量が少なく、精神的負担も軽く、ちょうど良い。
- 大規模プロジェクト/複数のチーム/厳格な基準が求められる場合 → Redux Toolkit。プロセスを標準化することで、異なるチーム間でも一貫したコーディングスタイルを確保できます。
- 両方を組み合わせて使用するのも一般的なアプローチです。ページ間で共有されるグローバルな状態にはZustandを、複雑なビジネスモジュールにはRedux Toolkitを使用します。
❓ よくある質問
createSliceはアクションタイプとアクションクリエーターを自動生成し、configureStoreはミドルウェアとDevToolsを自動的に統合し、createAsyncThunkは非同期ワークフローを簡素化します。これにより、コード量が約60%削減されます。Zustand ストアはコンポーネントの外でも使用できますか?useCartStore.getState() どこからでも現在の状態を取得でき、useCartStore.setState(...) どこからでも状態を更新できます。これは、ルートガード、インターセプター、WebSocket コールバックなどで状態にアクセスする際に非常に便利です。useReducerではニーズを満たせない場合にのみ、状態管理ライブラリを使用すべきですか?useReducerは、単一のコンポーネント内や局所的なスコープにおける複雑な状態ロジックに適しています。ZustandやRedux Toolkitを導入すべきなのは、互いに関連のない複数のコンポーネント間で状態を共有する必要がある場合、状態を永続化する必要がある場合、あるいはミドルウェアが必要な場合に限られます。単に「使うためだけに」導入してはいけません。set関数とReactのsetStateの違いは何ですか?setはデフォルトで浅いマージを実行しますが(クラスコンポーネントのsetStateと同様)、ImmerをベースにしたRedux Toolkitのリデューサーは、深い不変の更新を実行します。また、Zustandではimmerミドルウェアを使用することで、Redux Toolkitと同様の可変更新構文を実現することも可能です。どちらのアプローチにも使用上の長所と短所がありますので、チームの好みに応じて選択してください。📖 まとめ
- Zustandは、ミニマルなAPI、
create(set => ({ state, actions }))、プロバイダーがゼロであること、および選択的なサブサブスクリプションで知られています。 - Persistence Middleware
persistたった1行のコードで状態をlocalStorageに自動的に同期 - devtools ミドルウェアは Zustand を Redux DevTools と統合し、タイムトラベルデバッグを可能にします
- Redux Toolkit は標準化されたワークフローを提供します:createSlice → configureStore → Provider → useSelector/useDispatch
createAsyncThunkは、非同期操作の 3 つの状態(保留中、完了、拒否)を自動的に管理します。- 小規模なプロジェクトにはZustandを、大規模なプロジェクトにはRedux Toolkitをお勧めします。これらを併用することも可能です。
- ミドルウェアシステム(Zustandのネストされた関数とRedux Toolkitのビルダーパターン)は、柔軟な拡張性を提供します
- Zustandは状態共有のシナリオの80%に適しているのに対し、Redux Toolkitは厳格な仕様が求められる複雑なビジネスモジュールに適しています。
- どの選択肢を選んだとしても、プロジェクトの途中で変更することによる移行コストを避けるため、ステート管理戦略はプロジェクトの早い段階で確定しておくことをお勧めします。
📝 練習問題
- Zustand を使用して「Todo Store」を作成する。タスクの追加、完了ステータスの変更、タスクの削除、完了済みタスクの一括削除、および未完了タスクの件数の集計に対応させること。リストコンポーネントと統計コンポーネントの両方でこれを使用し、選択的サブスクリプションの有効性を検証する。
- Todo Store に
persistミドルウェアを追加し、ページを更新した後もデータが保持されるかどうかを確認します。 - Redux Toolkit を使用してユーザー管理モジュールを実装する:
createAsyncThunkを使用して、ユーザー一覧の取得をシミュレートし(データが返されるまで 1 秒の遅延を設ける)、3 つの状態(読み込み中、エラー、データ)を管理し、コンポーネント内で読み込み状態、エラー状態、およびデータ一覧を表示する。



