注意: 私は数学の専門家ではない. この文章は,圏論・Algebraic Effects・Applicativeな構造の語彙を使ってReactを理解するための試論であり,厳密な定理としてではなく,読み方の提案として読んでほしい. 数学的に誤っている点があればぜひ教えてほしい.
序
「UI = f(State)」— Reactに関する言説の中で最も広く流通している等式の一つである. この等式は一見シンプルで,Reactの核心を捉えているように見える. 状態が決まればUIが決まる,と.
しかし,この等式を数学的な言葉で丁寧に読むと,いくつか確認すべき点が出てくる.
この
fは何なのか? 数学的な意味での「関数」なのか?もし関数なら,それはどの圏の射なのか?
Reactが言う「純粋」「冪等」は,数学におけるそれと同じか?
このエッセイでは,圏論・Algebraic Effects・Applicativeな構造の語彙を用いて,React Componentをより精密に読むことを試みる.
「純粋」の数学的意味
Reactのドキュメントや議論でよく使われる「純粋(pure)」という言葉の数学的意味を明確にしよう.
数学における 純粋関数 (pure function)とは,参照透過性(referential transparency)を持つ写像のことだ. 圏論の言葉で言えば,集合の圏Setにおける射(morphism)に対応する:
この射 f は以下を満たす:
同じ入力に対して常に同じ出力を返す(deterministic)
外部の状態を読み書きしない(no side effects)
値の計算以外のことをしない(no observable effects)
ここでReactのFunction Componentを考えてみよう:
function Greeting({ name }) {
const [count, setCount] = useState(0);
const theme = useContext(ThemeContext);
useEffect(() => {
document.title = `Hello, ${name}`;
}, [name]);
return (
<h1 style={{ color: theme.primary }}>
Hello, {name} ({count})
</h1>
);
}
このComponentは:
useStateを通じて内部状態を読み,更新手段を受け取っているuseContextを通じてコンポーネントツリーの外部状態を読んでいるuseEffectを通じて副作用をスケジュールしている
これはSetの射ではない. 引数(props)以外のものを読んでいるし,純粋な値の計算以外のことをしている.
React Componentは数学的な意味で「純粋関数」ではない.
ではなぜReactはComponentを「純粋」と呼ぶのか? それはReactが 異なる意味で「純粋」という言葉を使っているからだ. Reactにおける「純粋」とは:
レンダリング中に観測可能な副作用がないこと — より正確には,Reactのエフェクトシステムの中で正しく振る舞うこと
これは数学的純粋性よりもはるかに弱い条件であり,用語の混同は本質的な誤解を招く.
「冪等」の本来の意味
Reactのドキュメントには次のような記述がある(Components and Hooks must be pure):
"Components must be idempotent – React components are assumed to always return the same output with respect to their inputs – props, state, and context."
ここで使われている「冪等(idempotent)」は,数学的に何を意味するのか.
数学における冪等
代数学における冪等元の定義:
定義 (冪等元). モノイド
(M, ·, e)において,元a ∈ Mが冪等 (idempotent)であるとは,a · a = aを満たすことをいう.
これを関数に拡張すると:
定義 (冪等な自己準同型). 圏
𝒞において,射e: A → Aが冪等であるとは,e ∘ e = eを満たすことをいう.
この2つの定義は,冪等元については Eric W. Weisstein, "Idempotent," MathWorld を,冪等な自己準同型については Emily Riehl, Category Theory in Context, Example 3.2.14 を参照している.
具体例:
絶対値関数は冪等:
||x|| = |x|射影行列
Pは冪等:P^2 = PMath.floorは冪等:Math.floor(Math.floor(x)) === Math.floor(x)
ここで重要なのは,冪等は自己準同型(endomorphism) f: A -> A の性質であるということだ. f の出力にもう一度 f を適用しても結果が変わらない — これが冪等の本来の意味である.
Reactの「冪等」は数学的には冪等ではない
ReactがComponentについて「冪等」と言っているのは,「同じ入力に対して同じ出力を返す」という意味だ. これは数学的には冪等ではなく,決定性(determinism)あるいは 整合性(well-definedness)と呼ぶべきものだ.
なぜなら:
Componentの型は
Props -> VDOMであり,Props != VDOM. つまりそもそも自己準同型ですらない.f ∘ f = fを検証するためにはfの出力をfの入力に渡す必要がある. だがComponentの出力(VDOM)をComponentの入力(Props)に渡すことに意味はない.Reactが言っているのは「
f(x) = f(x)(同じ入力なら同じ出力)」であって,「f(f(x)) = f(x)」ではない.
f(x) = f(x) はあらゆる関数が定義上満たすべき性質であり,わざわざ「冪等」と呼ぶようなものではない. 正確に言えば,Reactが意図しているのは「Hookが内部状態を持っていても,同じ(props, state, context)の組に対して同じVDOMを返す」ということだろう. しかしこれは「決定的関数(deterministic function)」であり,「冪等(idempotent)」とは異なる概念だ.
レンダリング操作は冪等として表現できる
ただし,視点を変えるとReactには冪等として表現できる操作がある.
状態 s を固定し,ユーザーのEffectや外部I/Oをいったん捨象して,DOM状態だけを見る. その上でレンダリングとコミットの合成をひとつの操作として考えてみよう:
このとき:
同じ状態に対してレンダリング・コミットを2回適用しても,DOM状態だけを見れば1回適用した結果と同じになる. 2回目の適用は差分がなければno-opとして扱える. これはDOM状態変換として見たときの冪等である.
つまり,Reactにおいて冪等として扱うべき対象はComponent関数そのもの ではなく,理想化されたレンダリング・コミット操作 u_s の方だ. この区別は決定的に重要である.
React FiberとAlgebraic Effects
ここで,Reactの内部構造に目を向けよう.
React Fiber — Reactのコアランタイム — には,Algebraic Effectsを思わせる特徴がある. Dan Abramovも,Reactのいくつかの仕組みを考えるための見方としてAlgebraic Effectsを紹介している(Dan Abramov, "Algebraic Effects for the Rest of Us", 2019). ただし同記事は,Reactとの対応を「stretch」とし,SuspenseはAlgebraic Effectsそのものではなく,JavaScriptでは本当に継続をresumeしているわけではないとも明記している.
Algebraic Effectsとは
Algebraic Effectsは,計算とエフェクト(副作用)を分離するための数学的枠組みだ. この節の一般的な説明は,Plotkin and Power, "Algebraic Operations and Generic Effects" (2003) を参考にしている.
基本構造は3つの要素からなる:
エフェクトシグネチャ: 利用可能な操作の集合
計算(computation): エフェクト操作を呼び出しうるプログラム
ハンドラ(handler): エフェクト操作に意味を与える解釈器
これはReactの実装そのものではなく,対応関係を説明するための擬似コードだ:
// エフェクトシグネチャ
effect GetState : Unit → State
effect SetState : State → Unit
effect ReadProps : Unit → Props
effect ReadContext: Key → Value
effect Suspend : Promise<A> → A
effect Throw : Error → ⊥
// ハンドラ(= React Fiber ランタイム)
handler ReactFiber {
return vdom → vdom
ReadProps(_, resume) → resume(currentProps)
GetState(_, resume) → resume(currentFiber.memoizedState)
SetState(newState, resume) → enqueueUpdate(newState); resume(unit)
Suspend(promise, retryRender) → showFallback(); promise.then(() → retryRender())
Throw(error, _) → propagateToErrorBoundary(error)
}
ここで ユーザーが書くReact Function ComponentとReact Fiberランタイム の関係を次のように対応づけられる:
| Algebraic Effectsにおける役割 | Reactにおける対応 | |
|---|---|---|
| エフェクトシグネチャ | 利用可能な操作の宣言 | Hooks API (useState, useContext, use, ...) |
| 計算 | エフェクト操作を呼び出すプログラム | ユーザーのFunction Component |
| ハンドラ | 操作に意味を与える解釈器 | React Fiberランタイム |
この対応では,ReactのHooksはエフェクト操作として読める:
| Hook | エフェクト操作 | ハンドラ |
|---|---|---|
useProps |
ReadProps |
現在のprops |
useState |
GetState / SetState |
Fiberのstate queue |
useContext |
ReadContext |
Provider chainの探索 |
use(promise) |
Suspend |
Suspense boundary |
throw error |
Throw |
Error boundary |
useEffect |
ScheduleEffect |
commit phaseのeffect queue |
ここで useProps はReactの実在APIではない. 以降では,通常はComponentの関数引数として受け取るpropsも,ハンドラから現在のpropsを読む概念的なHook操作として一般化して扱う.
ユーザーが書くComponentはエフェクトフルな計算(effectful computation)として,React Fiberはそのハンドラ(解釈器)として見なせる.
ただし,これはReactのすべてをAlgebraic Effectsだけで説明できる,という主張ではない. 特にRules of Hooksが要求する「Hook呼び出し列の静的な形」は,通常のMonadやAlgebraic Effectsの語彙だけでは捉えにくい. ここでは,まずRules of Hooksという規約があり,それがComponent bodyの形に制約をかける. その結果として,Hook呼び出し列をApplicative / Free Applicative的な構造へ比較的忠実に埋め込める,という順序で考える.
Kleisli圏における関数合成
「UI = f(State)」で暗黙に想定されている「関数合成」も,数学的に検証する必要がある.
Kleisli圏
Monad T が与えられた圏 𝒞 に対して,Kleisli圏 𝒞_T を構成できる.
ここでのMonadは圏論的Monad,すなわち自己関手 T: 𝒞 → 𝒞 と自然変換 η: Id ⇒ T(unit),μ: T² ⇒ T(multiplication)の三つ組 (T, η, μ) でMonad則を満たすものとしている. MonadとKleisli圏の定義は Emily Riehl, Category Theory in Context, Definition 5.1.1 と Definition 5.2.10 を参照している:
対象:
𝒞と同じ射:
A -> Bin𝒞_TはA -> T(B)in𝒞合成:
f: A -> T(B)とg: B -> T(C)のKleisli合成は:
すなわち:
React ComponentはKleisli射
ここから先は,Reactの公式な形式意味論ではなく,Reactの各種エフェクトをひとつの抽象的なエフェクトMonadとして読むためのスケッチである. ReactのエフェクトMonadを便宜的に R と置く. JSの表面構文に合わせるなら,React Componentの型は次のように読める:
これはKleisli圏 Set_R における射 Props -> VDOM として読むための表現である. ただし,Hook操作のプログラム構造を揃えて扱うなら,propsも外側の引数ではなく,概念的なHook操作として内側に入れられる:
この読み方では,Props -> R(VDOM) は「propsを先に与える」表現であり,useProps を含む R(VDOM) は「propsをReact Fiberが解釈時に供給する」表現である.
Setの射(純粋関数)ではなく,Kleisli射(エフェクトフルな計算)として読む方が,Reactの振る舞いに近い.
コンポーネントの合成
JSXにおけるコンポーネントの合成を考えてみよう:
function Parent({ data }) {
const processed = use(processData(data));
return <Child items={processed} />;
}
この依存関係を抽象化すれば,Kleisli合成として次のようにスケッチできる:
ここで ∘_R はSetの通常の合成 ∘ ではなく,抽象化されたReactエフェクトMonadのKleisli合成 だ. useの呼び出し — つまりSuspendに相当するエフェクト — を経由しているため,純粋な関数合成では表現しにくい.
つまり,Reactにおける「関数合成」は,少なくともHooksやSuspenseまで含めて考えるなら,通常の意味での関数合成だけでは捉えきれない. これも「UI = f(State)」が素朴に正しくない理由のひとつである.
MonadのbindとApplicativeなスケルトン — Component Bodyの正体
Kleisli射の合成を理解したところで,Componentの関数bodyの中で何が起きているか をもう一段掘り下げよう.
Monad T に対して,bind演算(Haskellでは >>= と書かれる)は次の型を持つ:
これは「エフェクトフルな計算の結果を取り出して,次のエフェクトフルな計算に渡す」操作だ. Haskellのdo記法 はこのbindの連鎖を読みやすく書くための構文糖衣である:
-- do 記法
do
state <- getState
ctx <- readContext themeCtx
pure (view state ctx)
-- 脱糖後(bind の連鎖)
getState >>= \state ->
readContext themeCtx >>= \ctx ->
pure (view state ctx)
この見方は,ReactのComponent bodyを「エフェクトを順に呼び出し,その結果を使ってVDOMを返す計算」として読む上では有用だ. useProps まで含めて抽象化すると,たとえば:
function Component() {
const props = useProps(); // 概念的なHook操作: ReadProps >>= \props ->
const [state, setState] = useState(init); // ← bind: GetState >>= \state ->
const ctx = useContext(ThemeCtx); // ← bind: ReadContext >>= \ctx ->
return <View name={props.name} state={state} ctx={ctx} />; // ← pure: return (View props state ctx)
}
React Componentのbodyは,局所的にはReactエフェクトMonadにおけるdo記法に近いものとして読める.
ただし,ここで注意が必要だ. 通常のMonadはReactが要求する「Hook呼び出し列の静的性」を表すには強すぎる. bindは前段の計算結果に応じて,次に実行するエフェクトの形そのものを変えられるからだ:
do
x <- action1
if condition x
then do { y <- action2; pure (f x y) } -- action2 がある
else pure (g x) -- action2 がない
これはMonad計算としては自然に書ける. しかしReactのHooksでは,このように値や分岐に応じてHook列の形を変えることが禁止されている.
この「エフェクトの形は先に決まっていて,値だけが後から流れ込む」という性質は,Monadよりも Applicative の方が近い. Applicative F は,おおよそ次の操作を持つ:
ここで ap_F は,Haskellでいう <*> に対応する.
Applicativeにはbindのような F(A) -> (A -> F(B)) -> F(B) はない. そのため,前のエフェクトの結果に応じて後続のエフェクト構造を変えることはできない. 代わりに,どのエフェクトをどの順序で使うかというスケルトンが静的に決まり,その結果を純粋な関数へ流し込む 形になる.
Rules of Hooksを満たすComponentに限ってかなり粗くスケッチすれば,Hook呼び出し列は次のようなApplicative的な形として読める:
pure view <*> useProps <*> useState init <*> useContext themeCtx
もちろんこれはReactの実装ではない. しかし,propsも useProps という概念的Hookとして含めると,「入力とHookの列は固定され,そこから得た値でJSXを構成する」という構造を表すには,bindの連鎖よりもApplicativeのスケルトンの方が近い.
Rules of HooksがApplicativeなスケルトンへの埋め込みを可能にする
ここで,ReactのRules of Hooksを見直す:
Hookをループ,条件分岐,ネストされた関数の中で呼んではならない
Hookは常にFunction Componentのトップレベルで呼ばなければならない
順序としては,この規約が先にある. つまり「Applicativeな表現がRules of Hooksを説明する」というより,Rules of HooksがComponent bodyの形を制限することで,useProps を含むHook操作からなるApplicativeなスケルトンへ埋め込めるようにしている と考える方が自然だ.
この意味での「忠実な埋め込み」とは,Hook呼び出しの個数と順序を落とさずに,プログラム構造として取り出せるという意味である.
Reactではこれに相当するものが禁止されている:
// NG: 条件分岐の中の Hook
function Component({ condition }) {
const [x] = useState(0);
if (condition) {
const y = useContext(Ctx); // ← Rules of Hooks 違反
return <A x={x} y={y} />;
}
return <B x={x} />;
}
なぜか? React FiberはHookの呼び出し順序をlinked listとして保持している. レンダリングごとにHook操作の個数や順序が変わると,Fiberは前回のレンダリング結果と今回のHook呼び出しを正しく対応させられない.
一方で,Hook列を先に固定し,その後で値に応じて返すUIを分岐させることはできる:
// OK: Hook列は固定されている
function Component({ condition }) {
const [x] = useState(0);
const y = useContext(Ctx);
return condition ? <A x={x} y={y} /> : <B x={x} />;
}
つまり,Reactが静的に保ちたいのは「値を使ったレンダリング結果」ではなく,Hook操作のプログラム構造 である. Rules of Hooksによってこの形が固定されるからこそ,FreeApplicative HookOp,あるいはコンポーネントツリーの再帰性まで含めるなら Fix (FreeApplicative HookOp) のような構造へ写す余地が生まれる.
この方向の参考実装として,ubugeeei/mreact がある. React HooksをHaskellのindexed Monadとして表現し,Hookの呼び出し順序を型レベルのlistとして表現することで,Rules of Hooksを型検査で扱う実験である. ただし,本稿の整理では,この「型レベルのHook列」はReactに後から貼り付ける表現というより,Rules of Hooksが先に固定しているプログラム形を,Applicative / Free Applicative的な静的スケルトンとして写し取ったものだ,と位置づけ直す.
useとSuspense — Algebraic Effectsを思わせる具象
use HookとSuspenseは,Reactの中でもAlgebraic Effectsを思わせる振る舞いが最も見えやすい部分だ. ただし,これはReactの実装が本物のAlgebraic Effectsを持つという意味ではない.
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId={1} />
</Suspense>
);
}
このメカニズムを,Algebraic Effectsの語彙で記述してみる:
1. use(promise)はエフェクト操作の実行(perform)に対応する:
Promiseが未解決なら,Reactの公開APIとしてはそのComponentがsuspendする(useはSuspenseと統合されている). これはAlgebraic Effectsにおける「操作の呼び出し」を思わせる非局所的制御フロー(non-local control flow)として読める.
2. Suspenseはエフェクトハンドラ(handler)に対応する:
Suspenseはsuspendを受け止め,fallback UIを表示し,Promiseがresolveしたらレンダリングを再試行する.
3.再レンダリング時にuse(promise)は解決済みの値を返す:
これはAlgebraic Effectsにおける「継続に値を渡して再開する」操作に似ている. ただし,ReactはJavaScriptの実行スタックを本当に保存してその地点から再開しているわけではなく,Promiseの解決後にComponent treeを再レンダリングする. この点は React Suspense docs と Dan Abramov, "Algebraic Effects for the Rest of Us" の注意に基づいている.
擬似的なハンドラ記法で表現すると:
handle(UserProfile(userId)) with {
return vdom → vdom
Suspend(promise, retryRender) →
display <Loading />;
await promise;
retryRender() // ← 本物の継続再開ではなく,再レンダリング
}
ここで特に重要なのは,useがComponentのレンダリングを中断し,Reactが後で再レンダリングできる ということだ.
通常のJavaScript関数は,呼び出したら最後まで実行される. async / awaitなら await の地点から継続できるが,React Componentはasync functionとして書かれていない. それでもReactはSuspense boundaryと再レンダリングによって,見かけ上は「そこで待って,値が来たら続きを評価した」ような体験を作る. この点が,Algebraic Effects(あるいは限定継続 / delimited continuation)を思わせる.
Client-Server「同型」の誤り
「同型JavaScript (Isomorphic JavaScript)」あるいは「ユニバーサルJavaScript」という言葉がある. Server Componentsの文脈でもしばしば使われるが,この「同型」は数学的に正しいのか.
圏論における同型
定義 (同型射). 圏
𝒞において,射f: A → Bが同型射 (isomorphism)であるとは,射g: B → Aが存在してg ∘ f = id_Aかつf ∘ g = id_Bを満たすことをいう.
この定義は,Emily Riehl, Category Theory in Context, Definition 1.1.10 における同型射の定義に沿っている.
同型は「構造を保存する可逆な変換」を意味する. A と B が同型ならば,圏論的には区別できない.
ServerレンダリングとClientレンダリングは同型ではない
サーバーとクライアントのレンダリングを関手として考える:
そもそも HTML != DOM である. 出力カテゴリが異なるので,F_S と F_C の間に同型を構成する余地はない.
HTML文字列からDOMへの変換(パース)は存在するが,その逆(DOM → HTML)はシリアライズであり,これらの合成が恒等射になるとは限らない(イベントハンドラ,内部状態,クロージャなどは失われる).
より正確な記述: 異なるエフェクトハンドラ
Algebraic Effectsの枠組みでは,サーバーレンダリングとクライアントレンダリングは同じエフェクトフルな計算に対する異なるハンドラとして理解できる:
Server ComponentsとClient Componentsの違いは,React公式ドキュメントが説明するように,実行環境と利用可能なAPIの違いとして現れる. この文章の読み方では,それを利用可能なエフェクトシグネチャの違い として捉える:
Server Component: DBクエリ,ファイルシステムアクセスなどのサーバー側の処理を直接使える.
useState,useEffectなど,多くのHooksは使えない.Client Component:
useState,useEffectなどのクライアント側のAPIを使える. サーバー専用コードをClient module subtreeへ直接持ち込むことはできない.
これは同型ではなく,エフェクトシグネチャ間の包含関係あるいはサブタイピングとして理解すべきものだ.
共有される部分は,利用箇所によってServer ComponentにもClient ComponentにもなりうるComponentとして動作する. サーバー専用の操作はServer側でのみ,クライアント専用の操作はClient側でのみ利用できる. これは「同型」ではなく,エフェクトシグネチャの部分的な重なりに基づく互換性(compatibility)として見る方がよい.
結: UI = f(State)を書き直す
ここまでの議論をまとめよう.
素朴な等式の問題
この等式の問題点:
fは純粋関数ではない — Hooksを通じてエフェクトを実行する「冪等」の誤用 — Component関数は数学的に冪等ではない(冪等として表現できるのはレンダリング操作)
合成が通常の関数合成だけでは捉えきれない — エフェクトの解釈はKleisli合成として表現できるが,Rules of Hooksで固定されたHook列はApplicativeな構造として見る方が自然
Client-Serverは同型ではない — 異なるエフェクトハンドラとして表現できる
より正確な記述
React Componentは,この文章の読み方では,JSの表面構文上はエフェクトMonad R のKleisli射として表現できる:
ただし,Hook操作のプログラム構造を揃えて扱うなら,propsも外側の引数としてではなく,概念的なHook操作として内側に入れる:
この場合,この式が捉えるのは「ComponentがReactランタイムに解釈されるエフェクトフルな計算である」という側面であって,Rules of Hooksが要求する静的なHook列そのものではない. Hook操作のプログラム構造については,Rules of Hooksが先に形を固定している. その制約があるから,ReadProps も含めて次のようなApplicative / Free Applicative的なスケルトンへ写せる:
UIの生成は,エフェクトハンドラ h による解釈として表せる:
ここで:
ComponentProgram: HookProgram(VDOM)は,useProps,useState,useContextなどを含む,ユーザーが書くエフェクトフルな計算の記述h_props: HookProgram(VDOM) -> DOMは,現在のpropsを供給しながらReact Fiberランタイムが計算を解釈することの表現
そして,ユーザーのEffectや外部I/Oを捨象したDOM状態変換として見るなら,レンダリング操作 u_s = h(render(s)) はDOM上の冪等な自己準同型として表現できる:
は教育的な直感としては有用だが,Reactの振る舞いを数学的に細かく見るには,いくつかの補足が必要になる.
React Componentは数学的な意味での純粋関数そのものではない. React Fiberによる解釈という側面はAlgebraic Effectsシステムにおけるエフェクトフルな計算として表現できる. 一方で,静的なHook列については表現がRules of Hooksを説明するのではなく,Rules of Hooksが先にプログラム形を制約する. その結果として,propsも useProps として含めたApplicative / Free Applicative的な構造へ忠実に写せる,と読む方が自然だ.
この認識によって,Reactの「ルール」やHooksの振る舞い,useとSuspenseの仕組み,Server Componentsの設計原理を,散在する個別知識としてではなく,エフェクトの解釈 と 静的なHookスケルトン という二層に分けて見通しやすくなる.