状態管理(store)
実装:
frontend/store// 実行:pnpm test(frontend/)
アプリが育つと状態がどこからでも変わり、追えなくなる。storeはこれを一本道に縛る。状態は読み取り専用で、変更は必ずactionをdispatchする。actionは純粋なreducerに渡り、古い状態を書き換えず新しい状態を返して購読者へ通知する。この一方向の流れが状態変化を追跡可能にする。中核とcombineReducers・セレクタ・middlewareを実装する。
この章で作るもの
リアクティビティ(signal)は、状態が変わると購読者が自動で反応する暗黙の仕組みだった。手軽な反面、規模が大きくなると「この状態は、いつ、どこから変えられたのか」が追いにくくなる。あちこちのコンポーネントが直接状態を書き換えると、バグが起きたとき原因の特定が難しい。store は逆の哲学を採る。状態の変え方を、たった一本の道に縛る。
その道が単方向データフローだ。状態は読み取り専用で、誰も直接書き換えられない。変えたいときは action(「カウントを増やす」という意図のオブジェクト)を dispatch する。action は純粋な reducer に渡り、reducer は今の状態と action から新しい状態を計算して返す。古い状態は一切変えない。新しい状態ができたら、購読しているすべての場所に通知する。すべての状態変化がこの一本道を通るので、「何が状態を変えたか」は必ず action として記録に残る。Redux が広めたこの構えを、この章では最小構成で実装する。
dispatch(action)
│
▼
┌─ reducer ─┐ (state, action) → 新state(古いstateは不変)
│ 純粋関数 │
└─────┬─────┘
▼
新しい state ──通知──▶ 購読者(UI を再描画)
▲
└── 次の dispatch へ(一方向の輪)順に見ていく。
- 単方向データフロー: 状態を直接触らず、action → reducer → 新state → 通知の一本道
- 純粋な reducer とイミュータブル: 古い状態は不変。だから予測可能で、時間旅行やデバッグができる
- 分割とキャッシュ: combineReducers で状態を分け、セレクタで派生値をメモ化する
① store の中核: dispatch・reducer・subscribe
まず store の心臓を作る。状態を持ち、action を dispatch すると reducer で新しい状態にし、購読者へ通知する:
// 単方向データフローの状態管理(Redux 風)を最小構成でフルスクラッチする。
//
// signal のリアクティビティは「状態が購読者を覚える」暗黙の仕組みだった。
// store は逆に、状態の変え方を明示的に一本道へ縛る。状態は読み取り専用で、
// 変更は必ず action を dispatch する。action は純粋な reducer に渡り、
// reducer は古い状態を書き換えず新しい状態を返す。変更のたびに購読者へ通知する。
// この一方向の流れが、状態変化を追跡可能で予測可能にする。
//
// 肝は3つ:
// 1. 単方向: 状態を直接触らず action → reducer → 新state → 通知の一本道
// 2. 純粋な reducer + イミュータブル: 古い状態は不変。時間旅行やデバッグができる
// 3. セレクタ: 状態からの派生値を、入力が変わるまでキャッシュする
// #region store{ts}
export interface Action {
type: string;
[key: string]: unknown;
}
export type Reducer<S> = (state: S, action: Action) => S;
export type Listener = () => void;
export type Dispatch = (action: Action) => Action;
export interface Store<S> {
getState(): S;
dispatch: Dispatch;
subscribe(listener: Listener): () => void;
}
// MiddlewareAPI は middleware が使える最小の store インターフェース。
export interface MiddlewareAPI<S> {
getState(): S;
dispatch: Dispatch;
}
// Middleware は dispatch を包んで前後に処理を挟む(ログ、非同期など)。
export type Middleware<S> = (api: MiddlewareAPI<S>) => (next: Dispatch) => Dispatch;
// createStore は reducer と初期状態から store を作る。middleware で dispatch を拡張できる。
export function createStore<S>(reducer: Reducer<S>, preloaded: S, ...middlewares: Middleware<S>[]): Store<S> {
let state = preloaded;
const listeners = new Set<Listener>();
// 素の dispatch: reducer で新しい状態を作り、購読者へ通知する。
const baseDispatch: Dispatch = (action) => {
state = reducer(state, action); // 直接書き換えず、返り値で置き換える
for (const l of [...listeners]) l();
return action;
};
// middleware を右から巻いて dispatch を作る。
let dispatch: Dispatch = baseDispatch;
if (middlewares.length > 0) {
const api: MiddlewareAPI<S> = { getState: () => state, dispatch: (a) => dispatch(a) };
const chain = middlewares.map((m) => m(api));
dispatch = chain.reduceRight((next, mw) => mw(next), baseDispatch);
}
return {
getState: () => state,
dispatch,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // 購読解除
},
};
}
// #endregion store{ts}
// #region combine{ts}
// combineReducers は複数の reducer を、状態のスライスごとに束ねる。
// 各 reducer は自分の担当スライスだけを見て更新する。
export function combineReducers<S>(reducers: {
[K in keyof S]: Reducer<S[K]>;
}): Reducer<S> {
const keys = Object.keys(reducers) as (keyof S)[];
return (state, action) => {
let changed = false;
const next = {} as S;
for (const key of keys) {
const prevSlice = state[key];
const nextSlice = reducers[key](prevSlice, action);
next[key] = nextSlice;
if (nextSlice !== prevSlice) changed = true;
}
return changed ? next : state; // 何も変わらなければ同じ参照を返す
};
}
// #endregion combine{ts}
// #region selector{ts}
// createSelector は状態からの派生値を、入力が変わるまでキャッシュする(メモ化)。
// inputs で状態から値を取り出し、それらが前回と同じなら result を再計算しない。
export function createSelector<S, Inputs extends unknown[], R>(
inputs: { [K in keyof Inputs]: (state: S) => Inputs[K] },
result: (...args: Inputs) => R,
): (state: S) => R {
let lastArgs: Inputs | null = null;
let lastResult: R;
return (state) => {
const args = inputs.map((fn) => fn(state)) as Inputs;
if (lastArgs !== null && args.length === lastArgs.length && args.every((a, i) => Object.is(a, lastArgs![i]))) {
return lastResult; // 入力が同じ = 再計算しない
}
lastArgs = args;
lastResult = result(...args);
return lastResult;
};
}
// #endregion selector{ts}baseDispatch が一本道そのものだ。state = reducer(state, action) で、reducer の返り値に状態を置き換える。ここで reducer は古い状態を書き換えず、新しいオブジェクトを返すのが約束だ。だから dispatch の前後で、古い状態オブジェクトは変わらない。テストで、dispatch しても前の状態の参照と値が変わらないこと(イミュータブル)、未知の action では状態が同じ参照のままなことを固定した。この不変性が効くのは、UI が「状態が変わったか」を参照の比較(before !== after)だけで判定できるからだ。中身を深く比べる必要がない。middleware は、この dispatch を包んで前後に処理を挟む拡張点だ(後述)。
② combineReducers: 状態を分割する
アプリの状態は 1 つの塊ではなく、複数の関心事(カウンタ、todoリスト、ユーザ情報…)の集まりだ。1 つの巨大な reducer にすべてを詰めると手に負えなくなる。combineReducers は、状態をスライスに分け、各スライスを専用の reducer に担当させる:
// 単方向データフローの状態管理(Redux 風)を最小構成でフルスクラッチする。
//
// signal のリアクティビティは「状態が購読者を覚える」暗黙の仕組みだった。
// store は逆に、状態の変え方を明示的に一本道へ縛る。状態は読み取り専用で、
// 変更は必ず action を dispatch する。action は純粋な reducer に渡り、
// reducer は古い状態を書き換えず新しい状態を返す。変更のたびに購読者へ通知する。
// この一方向の流れが、状態変化を追跡可能で予測可能にする。
//
// 肝は3つ:
// 1. 単方向: 状態を直接触らず action → reducer → 新state → 通知の一本道
// 2. 純粋な reducer + イミュータブル: 古い状態は不変。時間旅行やデバッグができる
// 3. セレクタ: 状態からの派生値を、入力が変わるまでキャッシュする
// #region store{ts}
export interface Action {
type: string;
[key: string]: unknown;
}
export type Reducer<S> = (state: S, action: Action) => S;
export type Listener = () => void;
export type Dispatch = (action: Action) => Action;
export interface Store<S> {
getState(): S;
dispatch: Dispatch;
subscribe(listener: Listener): () => void;
}
// MiddlewareAPI は middleware が使える最小の store インターフェース。
export interface MiddlewareAPI<S> {
getState(): S;
dispatch: Dispatch;
}
// Middleware は dispatch を包んで前後に処理を挟む(ログ、非同期など)。
export type Middleware<S> = (api: MiddlewareAPI<S>) => (next: Dispatch) => Dispatch;
// createStore は reducer と初期状態から store を作る。middleware で dispatch を拡張できる。
export function createStore<S>(reducer: Reducer<S>, preloaded: S, ...middlewares: Middleware<S>[]): Store<S> {
let state = preloaded;
const listeners = new Set<Listener>();
// 素の dispatch: reducer で新しい状態を作り、購読者へ通知する。
const baseDispatch: Dispatch = (action) => {
state = reducer(state, action); // 直接書き換えず、返り値で置き換える
for (const l of [...listeners]) l();
return action;
};
// middleware を右から巻いて dispatch を作る。
let dispatch: Dispatch = baseDispatch;
if (middlewares.length > 0) {
const api: MiddlewareAPI<S> = { getState: () => state, dispatch: (a) => dispatch(a) };
const chain = middlewares.map((m) => m(api));
dispatch = chain.reduceRight((next, mw) => mw(next), baseDispatch);
}
return {
getState: () => state,
dispatch,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // 購読解除
},
};
}
// #endregion store{ts}
// #region combine{ts}
// combineReducers は複数の reducer を、状態のスライスごとに束ねる。
// 各 reducer は自分の担当スライスだけを見て更新する。
export function combineReducers<S>(reducers: {
[K in keyof S]: Reducer<S[K]>;
}): Reducer<S> {
const keys = Object.keys(reducers) as (keyof S)[];
return (state, action) => {
let changed = false;
const next = {} as S;
for (const key of keys) {
const prevSlice = state[key];
const nextSlice = reducers[key](prevSlice, action);
next[key] = nextSlice;
if (nextSlice !== prevSlice) changed = true;
}
return changed ? next : state; // 何も変わらなければ同じ参照を返す
};
}
// #endregion combine{ts}
// #region selector{ts}
// createSelector は状態からの派生値を、入力が変わるまでキャッシュする(メモ化)。
// inputs で状態から値を取り出し、それらが前回と同じなら result を再計算しない。
export function createSelector<S, Inputs extends unknown[], R>(
inputs: { [K in keyof Inputs]: (state: S) => Inputs[K] },
result: (...args: Inputs) => R,
): (state: S) => R {
let lastArgs: Inputs | null = null;
let lastResult: R;
return (state) => {
const args = inputs.map((fn) => fn(state)) as Inputs;
if (lastArgs !== null && args.length === lastArgs.length && args.every((a, i) => Object.is(a, lastArgs![i]))) {
return lastResult; // 入力が同じ = 再計算しない
}
lastArgs = args;
lastResult = result(...args);
return lastResult;
};
}
// #endregion selector{ts}各 reducer は自分の担当スライスだけを見て、そのスライスの新しい値を返す。全体の reducer は、それらをまとめて新しい状態オブジェクトを組む。ここで大事なのが、変化のないスライスは同じ参照を保つことだ。カウンタだけを変える action では、todos スライスの reducer は同じ配列をそのまま返すので、todos の参照は変わらない。テストで、inc action の後も todos が同じ参照であることを固定した。これにより、UI は「todos は変わっていない」を参照比較だけで判定でき、todos に依存する部分の再描画を丸ごとスキップできる。
③ セレクタと middleware
状態から派生した値(「完了した todo の数」「合計金額」)を、UI は頻繁に読む。毎回計算し直すのは無駄だ。セレクタは、入力(状態の一部)が変わるまで結果をキャッシュする:
// 単方向データフローの状態管理(Redux 風)を最小構成でフルスクラッチする。
//
// signal のリアクティビティは「状態が購読者を覚える」暗黙の仕組みだった。
// store は逆に、状態の変え方を明示的に一本道へ縛る。状態は読み取り専用で、
// 変更は必ず action を dispatch する。action は純粋な reducer に渡り、
// reducer は古い状態を書き換えず新しい状態を返す。変更のたびに購読者へ通知する。
// この一方向の流れが、状態変化を追跡可能で予測可能にする。
//
// 肝は3つ:
// 1. 単方向: 状態を直接触らず action → reducer → 新state → 通知の一本道
// 2. 純粋な reducer + イミュータブル: 古い状態は不変。時間旅行やデバッグができる
// 3. セレクタ: 状態からの派生値を、入力が変わるまでキャッシュする
// #region store{ts}
export interface Action {
type: string;
[key: string]: unknown;
}
export type Reducer<S> = (state: S, action: Action) => S;
export type Listener = () => void;
export type Dispatch = (action: Action) => Action;
export interface Store<S> {
getState(): S;
dispatch: Dispatch;
subscribe(listener: Listener): () => void;
}
// MiddlewareAPI は middleware が使える最小の store インターフェース。
export interface MiddlewareAPI<S> {
getState(): S;
dispatch: Dispatch;
}
// Middleware は dispatch を包んで前後に処理を挟む(ログ、非同期など)。
export type Middleware<S> = (api: MiddlewareAPI<S>) => (next: Dispatch) => Dispatch;
// createStore は reducer と初期状態から store を作る。middleware で dispatch を拡張できる。
export function createStore<S>(reducer: Reducer<S>, preloaded: S, ...middlewares: Middleware<S>[]): Store<S> {
let state = preloaded;
const listeners = new Set<Listener>();
// 素の dispatch: reducer で新しい状態を作り、購読者へ通知する。
const baseDispatch: Dispatch = (action) => {
state = reducer(state, action); // 直接書き換えず、返り値で置き換える
for (const l of [...listeners]) l();
return action;
};
// middleware を右から巻いて dispatch を作る。
let dispatch: Dispatch = baseDispatch;
if (middlewares.length > 0) {
const api: MiddlewareAPI<S> = { getState: () => state, dispatch: (a) => dispatch(a) };
const chain = middlewares.map((m) => m(api));
dispatch = chain.reduceRight((next, mw) => mw(next), baseDispatch);
}
return {
getState: () => state,
dispatch,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // 購読解除
},
};
}
// #endregion store{ts}
// #region combine{ts}
// combineReducers は複数の reducer を、状態のスライスごとに束ねる。
// 各 reducer は自分の担当スライスだけを見て更新する。
export function combineReducers<S>(reducers: {
[K in keyof S]: Reducer<S[K]>;
}): Reducer<S> {
const keys = Object.keys(reducers) as (keyof S)[];
return (state, action) => {
let changed = false;
const next = {} as S;
for (const key of keys) {
const prevSlice = state[key];
const nextSlice = reducers[key](prevSlice, action);
next[key] = nextSlice;
if (nextSlice !== prevSlice) changed = true;
}
return changed ? next : state; // 何も変わらなければ同じ参照を返す
};
}
// #endregion combine{ts}
// #region selector{ts}
// createSelector は状態からの派生値を、入力が変わるまでキャッシュする(メモ化)。
// inputs で状態から値を取り出し、それらが前回と同じなら result を再計算しない。
export function createSelector<S, Inputs extends unknown[], R>(
inputs: { [K in keyof Inputs]: (state: S) => Inputs[K] },
result: (...args: Inputs) => R,
): (state: S) => R {
let lastArgs: Inputs | null = null;
let lastResult: R;
return (state) => {
const args = inputs.map((fn) => fn(state)) as Inputs;
if (lastArgs !== null && args.length === lastArgs.length && args.every((a, i) => Object.is(a, lastArgs![i]))) {
return lastResult; // 入力が同じ = 再計算しない
}
lastArgs = args;
lastResult = result(...args);
return lastResult;
};
}
// #endregion selector{ts}createSelector は、inputs で状態から値を取り出し、それらが前回と同じ参照なら result を再計算しない。combineReducers が「変化のないスライスは同じ参照」を保証してくれるので、これがうまく噛み合う。todos が変わらなければ todos の参照も変わらず、todos から派生するセレクタは再計算をスキップする。テストで、入力の配列が同じ参照の間は計算関数が一度しか呼ばれないことを固定した。
middleware は dispatch を包む拡張点だ。() => (next) => (action) => {...} という三段の関数で、action を next に渡す前後に好きな処理を挟める。ログを取る、action を記録する、あるいは関数を dispatch できるようにして非同期処理を扱う(thunk)。テストで、ログ middleware が action の前後を記録すること、thunk 風 middleware が関数 action を処理して複数の dispatch をまとめられることを固定した。dispatch という一点を包むだけで、横断的な関心事を差し込める。
動かす
下のデモは、action を dispatch して状態が一本道で変わる様子と、middleware が dispatch を包んでログを取る様子を見る。状態のイミュータブルな更新、購読者への通知、セレクタのキャッシュも確かめられる。
状態は直接触らず、action を dispatch する。reducer が古い状態を書き換えず新しい状態を返すので、 変更のたびに state オブジェクトの世代が上がる(イミュータブル)。未知の action は同じ参照を返し、 再描画が要らないと分かる。middleware は dispatch を包んで前後にログを挟む。すべての変更がこの 一本道を通るので、いつ何が状態を変えたかが必ず記録に残る。
設計の観点
- 単方向は追跡可能性のため: すべての変更が action を通るので、「何が状態を変えたか」が記録に残る。時間旅行デバッグ(action を巻き戻す)が可能になる
- reducer は純粋に保つ: API 呼び出しや乱数など副作用を reducer に入れない。純粋だからこそ、同じ入力で同じ結果になり、テストとリプレイができる。副作用は middleware や外側へ
- イミュータブルが参照比較を可能にする: 状態を書き換えず新オブジェクトを返すことで、UI は参照の等値だけで変更を検出できる。深い比較が要らず速い
- signal との使い分け: signal は局所的できめ細かい状態に手軽。store は大域的で、変更履歴の追跡や予測可能性が要る状態に向く。両者は排他でなく、併用もされる
- ボイラープレートの代償: action・reducer・型の定義は冗長になりがち。小さなアプリには過剰で、その反省から Zustand のような軽量な store も生まれた
対照と実例
| 直接変更 | signal | store(Redux) | |
|---|---|---|---|
| 変更の起点 | どこからでも | signal.set | dispatch のみ |
| 追跡可能性 | 低い | 中 | 高い(action 記録) |
| 粒度 | — | きめ細かい | 中央集権 |
| 派生値 | 手動 | computed | セレクタ |
| 向く規模 | 小 | 中 | 大・複雑 |
裏どり:
- Redux: 単方向データフロー・純粋 reducer・middleware の原型。この章の API はこれに近い
- Flux (Facebook): Redux の前身。単方向データフローの考え方を広めた
- Zustand / Jotai: Redux のボイラープレートを減らした軽量 store。用途で使い分ける
- Redux DevTools: action を記録し、状態を巻き戻せる時間旅行デバッガ。イミュータブルだから実現できる
簡略化したこと
- 非同期は thunk のみ: saga や observable ベースの非同期は扱わない
- 不変性は約束: reducer が誤って mutate しても防げない。実物は Immer や凍結で守る
- DevTools 連携なし: 時間旅行 UI やアクション記録は概念のみ
- 型は簡略: action は文字列 type + 任意フィールド。実物は判別可能ユニオンで厳密に
参考資料
- Redux: Core Concepts — 単方向データフローと reducer
- Redux: Middleware — dispatch を包む設計
- Reselect — createSelector のメモ化の元
- 実装: frontend/store