注意: 私は数学の専門家ではない. この文章は,圏論や代数的エフェクトの語彙を使ってReactを理解するための試論であり,厳密な定理としてではなく,モデル化の提案として読んでほしい. 数学的に誤っている点があればぜひ教えてほしい.
序
「UI = f(State)」— Reactに関する言説の中で最も広く流通している等式の一つである. この等式は一見シンプルで,Reactの核心を捉えているように見える. 状態が決まればUIが決まる,と.
しかし,この等式を数学的な言葉で丁寧に読むと,いくつか確認すべき点が出てくる.
この
fは何なのか? 数学的な意味での「関数」なのか?もし関数なら,それはどの圏の射なのか?
Reactが言う「純粋」「冪等」は,数学におけるそれと同じか?
このエッセイでは,圏論と代数的エフェクトの枠組みを用いて,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と代数的エフェクト
ここで,Reactの内部構造に目を向けよう.
React Fiber — Reactのコアランタイム — には,代数的エフェクト(Algebraic Effects)を思わせる特徴がある. Dan Abramovも,代数的エフェクトをReactのいくつかの仕組みを考えるためのメンタルモデルとして紹介している(Dan Abramov, "Algebraic Effects for the Rest of Us", 2019). ただし同記事は,Reactとの対応を「stretch」とし,Suspenseは代数的エフェクトそのものではなく,JavaScriptでは本当に継続をresumeしているわけではないとも明記している.
代数的エフェクトとは
代数的エフェクトは,計算とエフェクト(副作用)を分離するための数学的枠組みだ. この節の一般的な説明は,Plotkin and Power, "Algebraic Operations and Generic Effects" (2003) を参考にしている.
基本構造は3つの要素からなる:
エフェクトシグネチャ: 利用可能な操作の集合
計算(computation): エフェクト操作を呼び出しうるプログラム
ハンドラ(handler): エフェクト操作に意味を与える解釈器
これはReactの実装そのものではなく,対応関係を説明するための擬似コードだ:
// エフェクトシグネチャ
effect GetState : Unit → State
effect SetState : State → Unit
effect ReadContext: Key → Value
effect Suspend : Promise<A> → A
effect Throw : Error → ⊥
// ハンドラ(= React Fiber ランタイム)
handler ReactFiber {
return vdom → vdom
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ランタイム の関係を次のように対応づけられる:
| 代数的エフェクトにおける役割 | Reactにおける対応 | |
|---|---|---|
| エフェクトシグネチャ | 利用可能な操作の宣言 | Hooks API (useState, useContext, use, ...) |
| 計算 | エフェクト操作を呼び出すプログラム | ユーザーのFunction Component |
| ハンドラ | 操作に意味を与える解釈器 | React Fiberランタイム |
このモデルでは,ReactのHooksはエフェクト操作として読める:
| Hook | エフェクト操作 | ハンドラ |
|---|---|---|
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 |
ユーザーが書くComponentはエフェクトフルな計算(effectful computation)として,React Fiberはそのハンドラ(解釈器)として見なせる.
Kleisli圏における関数合成
「UI = f(State)」で暗黙に想定されている「関数合成」も,数学的に検証する必要がある.
Kleisli圏
モナド T が与えられた圏 𝒞 に対して,Kleisli圏 𝒞_T を構成できる.
ここでのモナドは圏論的モナド,すなわち自己関手 T: 𝒞 → 𝒞 と自然変換 η: Id ⇒ T(unit),μ: T² ⇒ T(multiplication)の三つ組 (T, η, μ) でモナド則を満たすものとしている. モナドと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の各種エフェクトをひとつの抽象的なエフェクトモナドとしてまとめるモデル化である. Reactのエフェクトモナドを仮に R とする. このときReact Componentの型は:
これはKleisli圏 Set_R における射 Props -> VDOM としてモデル化できる.
Setの射(純粋関数)ではなく,Kleisli射(エフェクトフルな計算)として読む方が,Reactの振る舞いに近い.
コンポーネントの合成
JSXにおけるコンポーネントの合成を考えてみよう:
function Parent({ data }) {
const processed = use(processData(data));
return <Child items={processed} />;
}
この依存関係を抽象化すれば,Kleisli合成として次のようにスケッチできる:
ここで ∘_R はSetの通常の合成 ∘ ではなく,抽象化されたReactエフェクトモナドのKleisli合成 だ. useの呼び出し — つまりSuspendに相当するエフェクト — を経由しているため,純粋な関数合成では表現しにくい.
つまり,Reactにおける「関数合成」は,少なくともHooksやSuspenseまで含めて考えるなら,通常の意味での関数合成だけでは捉えきれない. これも「UI = f(State)」が素朴に正しくない理由のひとつである.
モナドのbindとdo記法 — Component Bodyの正体
Kleisli射の合成を理解したところで,Componentの関数bodyの中で何が起きているか をもう一段掘り下げよう.
モナド 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を見てみよう:
function Component(props) {
const [state, setState] = useState(init); // ← bind: GetState >>= \state ->
const ctx = useContext(ThemeCtx); // ← bind: ReadContext >>= \ctx ->
return <View state={state} ctx={ctx} />; // ← pure: return (View state ctx)
}
React Componentのbodyは,Reactエフェクトモナドにおけるdo記法に近いものとして読める.
各Hook呼び出しは,このモデルではbind (>>=) に対応すると考えられる:
エフェクト操作を実行し (
T(A)を得る)その結果を変数に束縛し (
Aを取り出す)残りの計算に渡す (
A -> T(B)を適用する)
この対応関係を図式化すると:
| Haskell do記法 | React Component body |
|---|---|
x <- action |
const x = useXxx(...) |
pure expr |
return <JSX /> |
action1 >> action2 |
戻り値を使わないHook呼び出し(useEffect(...)) |
| bindの連鎖構造 | Hookの呼び出し順序 |
Rules of Hooksはbind構造の静的性の要請
ここで,ReactのRules of Hooksの意味も,このモデルの中では次のように読める:
Hookをループ,条件分岐,ネストされた関数の中で呼んではならない
Hookは常にFunction Componentのトップレベルで呼ばなければならない
このモデルではつまり,do記法のbind構造がレンダリングごとに静的でなければならない という要請として読める.
Haskellのdo記法で考えると,以下のようなコードは型が通っても意味論的に問題がある場合がある:
do
x <- action1
if condition
then do { y <- action2; pure (f x y) } -- bind が 2 つ
else pure (g x) -- bind が 1 つ
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の呼び出し順序(= bindの連鎖順序)をlinked listとして保持している. レンダリングごとにbindの個数や順序が変わると,Fiberは前回のレンダリング結果と今回のHook呼び出しを正しく対応させられない.
つまりRules of Hooksは,Reactエフェクトモナドのdo記法におけるbind構造が静的(固定)であることの要請 として読める. これはReactの実装上の制約であり,その制約をこのモデルの中ではモナド計算の構造的整合性として解釈できる.
この方向の参考実装として,ubugeeei/mreact がある. React HooksをHaskellのindexed monadとしてモデル化し,Hookの呼び出し順序を型レベルのlistとして表現することで,Rules of Hooksを型検査で扱う実験である.
useとSuspense — 代数的エフェクトを思わせる具象
use HookとSuspenseは,Reactの中でも代数的エフェクトを思わせる振る舞いが最も見えやすい部分だ. ただし,これはReactの実装が本物の代数的エフェクトを持つという意味ではない.
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId={1} />
</Suspense>
);
}
このメカニズムを,代数的エフェクトのメンタルモデルで記述してみる:
1. use(promise)はエフェクト操作の実行(perform)に対応する:
Promiseが未解決なら,Reactの公開APIとしてはそのComponentがsuspendする(useはSuspenseと統合されている). これは代数的エフェクトにおける「操作の呼び出し」を思わせる非局所的制御フロー(non-local control flow)として読める.
2. Suspenseはエフェクトハンドラ(handler)に対応する:
Suspenseはsuspendを受け止め,fallback UIを表示し,Promiseがresolveしたらレンダリングを再試行する.
3.再レンダリング時にuse(promise)は解決済みの値を返す:
これは代数的エフェクトにおける「継続に値を渡して再開する」操作に似ている. ただし,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と再レンダリングによって,見かけ上は「そこで待って,値が来たら続きを評価した」ような体験を作る. この点が,代数的エフェクト(あるいは限定継続 / 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)はシリアライズであり,これらの合成が恒等射になるとは限らない(イベントハンドラ,内部状態,クロージャなどは失われる).
より正確な記述: 異なるエフェクトハンドラ
代数的エフェクトの枠組みでは,サーバーレンダリングとクライアントレンダリングは同じエフェクトフルな計算に対する異なるハンドラとして理解できる:
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合成としてモデル化できる
Client-Serverは同型ではない — 異なるエフェクトハンドラとしてモデル化できる
より正確な記述
React Componentは,この文章のモデルではエフェクトモナド R のKleisli射として表せる:
UIの生成は,エフェクトハンドラ h による解釈として表せる:
ここで:
Component(Props): R(VDOM)は,ユーザーが書くエフェクトフルな計算の記述h: R(VDOM) -> DOMは,React Fiberランタイムによる解釈のモデル
そして,ユーザーのEffectや外部I/Oを捨象したDOM状態変換として見るなら,レンダリング操作 u_s = h(render(s)) はDOM上の冪等な自己準同型としてモデル化できる:
は教育的な直感としては有用だが,Reactの振る舞いを数学的に細かく見るには,いくつかの補足が必要になる.
React Componentは数学的な意味での純粋関数そのものではなく,代数的エフェクトシステムにおけるエフェクトフルな計算としてモデル化できる. そしてReact Fiberは,そのハンドラ(解釈器)として読むことができる.
この認識によって,Reactの「ルール」やHooksの振る舞い,useとSuspenseの仕組み,Server Componentsの設計原理を,散在する個別知識としてではなく,代数的エフェクトという一つの枠組みから統一的に見通しやすくなる.