/note/tech

Why Be Reactive?

要約:

■ 1. 記事の概要

  • Crank.jsの開発者Brian Kim氏による2025年8月20日の記事
  • リアクティブフレームワークは自動的なUI更新を約束するが微妙なバグやパフォーマンスの罠を生み出す
  • Crankの明示的なrefresh()呼び出しは制限ではなく野心的なWebアプリケーションを構築するためのスーパーパワー
  • リアクティブ抽象化の一般的な落とし穴を検証しCrankがリアクティブ抽象化を持たない哲学的根拠を提供

■ 2. Crank.jsの特徴

  • 「非リアクティブ」フレームワーク
  • コンポーネントは関数(async関数やジェネレータ関数を含む)
  • Promiseを直接コンポーネント内でawaitできる
  • 状態をローカル変数として定義できる
  • 状態が変更されたときにrefresh()を明示的に呼び出してビューを更新する
  • refresh()コールバックAPIの導入:
    • 状態変更をrefresh()コールバック内に置くことでrefresh()の呼び忘れが不可能になる
    • コールバック内のコードが再レンダリングを意図していることを宣言的に特定できる

■ 3. バグの重大度分析

  • バグのクラスの「重大度」を評価する2つの質問:
    • これらのバグは見つけやすいか?
    • これらのバグは修正しやすいか?
  • Crankの場合:
    • refresh()の呼び忘れバグはアプリが更新されていないことにすぐ気づくため見つけやすい
    • refresh()呼び出しを追加するだけで修正しやすい
  • リアクティブ抽象化は古いレンダリングを排除すると主張するが独自の落とし穴を生む

■ 4. Solid.jsの問題: リアクティビティの喪失

  • Solid.jsではpropsはリアクティブストア
  • propsをデストラクチャリングしたり派生値をJSX式の外で計算しようとすると失敗する
  • 壊れたコンポーネントはpropsが変更されても更新されない(値を抽出してpropsからJSXへのリアクティブ接続を壊すため)
  • バグの重大度:
    • 単純なケースはリンタールールで見つけやすいが複雑なアプリケーションにはリンターの隙間をすり抜けるエッジケースがある
    • 修正が難しい(フレームワークのリアクティブルールを理解する必要がある)
  • SolidはsplitPropsやmergePropsなどのユーティリティを提供する必要がある
  • Crankの明示的なrefreshモデルではこのクラスのバグは存在しない

■ 5. Vue.jsの問題: ディープリアクティビティのパフォーマンストラップ

  • Vueはプロキシを使用してリアクティブオブジェクトのプロパティと子を再帰的にリアクティブ抽象化でラップする
  • 深くネストされた状態が変更されたときにDOM更新を実行するのに便利
  • 問題点:
    • プロキシはプライベートクラスメンバーでは機能しない
    • プロキシはプリミティブには使用できない(VueがreactiveとrefのAPIを両方提供する理由)
    • 大きなオブジェクトや配列を深くプロキシするとパフォーマンスのボトルネックになる
  • Vueはパフォーマンス問題を回避するためにshallowRef()やmarkRaw()などのエスケープハッチを提供
  • 深くネストされた更新が再レンダリングを引き起こす便利さから追跡が必要な状態に移行
  • VueはisReactive()などのユーティリティを提供して開発者にデータのどの部分がリアクティブかを伝える必要がある
  • バグの重大度:
    • リアクティビティはデータ構造に不可視に追加されパフォーマンスのために選択的に削除されるため見つけにくい
    • 修正が難しい(状態が作成された場所まで遡ってなぜリアクティブかどうかを把握する必要がある)

■ 6. Svelteの問題: エフェクトと無限ループ

  • Svelte v4以前は代入がコンパイラによって計装されて再レンダリングをトリガー
  • Svelte v5では「runes」という特別な構文を導入($state()/$derived()/$effect()など)
  • エフェクトを使用するリアクティブ抽象化は無限ループに陥りやすい:
    • 同じ$state()ルーンを$effect()ルーンコールバック内で読み書きするとコールバックが発火し続ける
  • リアクティビティ支持者は「スプレッドシートのようなプログラミング」と称えるが多くの計算セルを持つスプレッドシートは遅い読み込みや開けない問題を抱える
  • 解決策はSvelteのuntrack()関数でルーンの読み取りを非リアクティブとしてマーク
  • バグの重大度:
    • 通常はすぐにスタックを吹き飛ばすが複雑なコンポーネントでは無限ループがすぐにトリガーされないエッジケースがある
    • $effect()ルーンはその中で実行されるすべてのコードを着色するためエフェクトコールバック内のコードだけでなくすべてのネストされた関数呼び出しもルーンに書き込まないようにする必要がある
    • 修正が困難(デバッグ時に状態をログに記録するだけで無限ループがトリガーされる可能性がある)
  • CrankにはエフェクトAPIがないため無限ループを引き起こさない

■ 7. 各フレームワークのエスケープハッチ

  • リアクティブ抽象化は手動更新管理を排除すると約束するがそれぞれ独自のエスケープハッチと回避策が必要:
    • Solid: splitPropsとmergeProps(propsを安全に操作するため)
    • Vue: shallowRefとmarkRaw(パフォーマンスの崖を避けるため)
    • Svelte: untrack()(無限ループを防ぐため)
  • これらのAPIはリアクティビティが更新の懸念から完全に隔離してくれないことを示している

■ 8. 実行透明性(Executional Transparency)

  • 参照透明性(Referential Transparency)はデータがどのように変換されるかを「見る」ことを容易にする
  • 実行透明性はコードがいつ実行されるかを「見る」ことに関する
  • Crankコードは明示性により実行透明性が高い:
    • コンポーネントは親によって更新されるかrefresh()が呼び出された場合にのみ実行される
  • Reactは最もリアクティブ抽象化が少ないにもかかわらず最も実行不透明:
    • 開発時にコンポーネントを二重レンダリング
    • useEffect()/useSyncExternalStore()/useTransition()などの混乱するAPI
    • コールバックがコールバックを返し任意のスケジューリングアルゴリズムの気まぐれで呼び出される
  • Reactエコシステムには「Why Did You Render」などのツールや過剰レンダリングのデバッグに関する無数の誤解とブログ記事がある

■ 9. 非リアクティビティはスーパーパワー

  • Webの最前線はTODOアプリではない
  • 難しいもの:
    • アニメーション
    • 仮想リスト
    • スクロールテリング
    • コンテンツ編集可能なコードエディタ
    • WebGLレンダラー
    • ゲーム
    • WebSocketストリームを使用したリアルタイムアプリケーション
    • 大規模データビジュアライゼーション
    • オーディオ/ビデオエディタ
    • 地図
  • リアクティブ抽象化はこれらの難しい問題に役立たない
  • コンポーネントがいつレンダリングされるかを明示的に制御することはスーパーパワー:
    • コードが実行される理由のコンテキストを維持できる
    • 必要なときに正確にレンダリングできる
    • これらの重要な決定を仲介するリーキーなリアクティブ抽象化がない