madorは「どんなDOMでもリアクティブにする」を掲げる小さなライブラリで、状態の読み書きを [r, w] という2つの関数に凝縮しています。フレームワークを使わずにReactの「状態が変わったら勝手に画面が更新される」を実現している核心部分が、JavaScript標準のProxyとReflectを使った30行弱のコードです。仕組みが全部見えるサイズなので、Proxyという概念を初めて触る人にちょうどいい教材として選びました。

コード

  // Temp. track reading & writing paths
  let activeEffect = null;
  let pendingPaths = [];
  // state proxy
  function state(target, path = []) {
    return new Proxy(target, {
      get(obj, prop) {
        const fullPath = path.concat(prop).join(".");
        activeEffect?.(fullPath);

        const val = Reflect.get(obj, prop);
        // make it recursive so we can 'track' each property
        if (val !== null && typeof val === "object" && !Array.isArray(val)) {
          return state(val, path.concat(prop));
        }
        return val;
      },
      set(obj, prop, value) {
        const fullPath = path.concat(prop).join(".");
        const oldVal = Reflect.get(obj, prop);
        // functional update support
        const newVal = typeof value === "function" ? value(oldVal) : value;

        if (oldVal !== newVal) {
          pendingPaths.push(fullPath);
          Reflect.set(obj, prop, newVal);
        }
        return true;
      },
    });
  }

引用はリポジトリの実物と機械で照合しています。「// …略…」は省略した行です。

上から順に読む

let activeEffect = null;
  let pendingPaths = [];

letは再代入できる変数を宣言するキーワードです。この2つの変数は、mador(initialState)という外側の関数の中で宣言されているので、この後出てくるstate関数やget/setトラップから見えるクロージャ変数になります。activeEffectは「今どのUI更新処理が状態を読んでいるか」を覚えておく箱、pendingPathsは「今回の書き込みで実際に変わったプロパティのパス」を溜めておく配列([]は空の配列リテラル)です。

function state(target, path = []) {

targetは監視したい生のオブジェクト(例えば {count: 0} )です。path = [] は「デフォルト引数」という書き方で、呼び出し時にpathを渡さなければ自動的に空配列が使われます。pathは今トップレベルからどこまで潜ってきたかを表す、プロパティ名の配列(例: ['user','name'])です。

return new Proxy(target, {

ProxyはJavaScript標準の組み込みオブジェクトで、「元のオブジェクト(target)に対する操作を横取りして、代わりに自分で決めた処理を実行する」ラッパーを作ります。new Proxy(target, handler) の第二引数handlerに、後述するget/setという名前の関数(トラップと呼びます)を渡すと、そのオブジェクトのプロパティを読んだり書いたりするたびにこのトラップが呼ばれるようになります。

get(obj, prop) {
        const fullPath = path.concat(prop).join(".");
        activeEffect?.(fullPath);

getトラップは「someObj.count のようにプロパティを読み取ったとき」に自動で呼ばれる関数で、obj(対象オブジェクト)とprop(読まれたプロパティ名、例:'count')を受け取ります。path.concat(prop)は配列pathの末尾にpropを追加した新しい配列を作り、.join(".")でそれを'.'区切りの文字列(例:'user.name')に変換します。activeEffect?.(fullPath) の?. はオプショナルチェイニングで、「activeEffectがnullやundefinedでなければ呼び出す、そうでなければ何もしない」という安全な関数呼び出しです。つまりここで「今このパスが読まれた」という情報を、実行中の更新処理に伝えています。

const val = Reflect.get(obj, prop);

Reflectは、Proxyのトラップの中で「本来(横取りしなかった場合)の動作」を安全に呼び出すための組み込みオブジェクトです。Reflect.get(obj, prop) は「objのpropプロパティの値を普通に取得する」処理で、obj[prop]と同じ結果になりますが、Proxyの中で使う定石の書き方です。

if (val !== null && typeof val === "object" && !Array.isArray(val)) {
          return state(val, path.concat(prop));
        }

typeof val === "object" は「valの型がオブジェクトかどうか」を調べます(ただしJavaScriptの仕様上nullも"object"型になるため、val !== nullで別途除外しています)。!Array.isArray(val) は「valが配列でない」ことの確認(!は否定)です。この3条件が全部真のとき、つまりネストしたオブジェクト(例: user.profile)が読まれたときは、そのオブジェクトをそのまま返さずに、もう一度state()を再帰呼び出ししてProxyで包み直してから返します。こうすることで、user.profile.name のようにさらに深い階層を読んでも同じ仕組みで追跡できます。

return val;

読まれた値が数値や文字列などのプリミティブ値(それ以上分解できない値)だった場合は、Proxyで包む必要がないのでそのまま返します。

set(obj, prop, value) {
        const fullPath = path.concat(prop).join(".");
        const oldVal = Reflect.get(obj, prop);

setトラップは「someObj.count = 5 のようにプロパティへ代入したとき」に自動で呼ばれます。valueは新しく代入しようとしている値です。ここでもgetと同じ要領でfullPathを組み立て、さらにReflect.getで「代入される前の古い値」を先に取得しておきます。

const newVal = typeof value === "function" ? value(oldVal) : value;

三項演算子(条件 ? 真の場合の値 : 偽の場合の値)です。もしvalueが関数なら、value(oldVal)を呼び出してその戻り値を新しい値とします。これはReactのsetState(prev => prev + 1)のような「関数的更新」を許すためのしくみで、そうでなければvalue自体をそのまま新しい値として使います。

if (oldVal !== newVal) {
          pendingPaths.push(fullPath);
          Reflect.set(obj, prop, newVal);
        }

!==は「型も値も含めて等しくない」ことを調べる厳密不等価演算子です。古い値と新しい値が実際に違っている場合だけ、pendingPaths(冒頭で宣言した配列)にfullPathをpush(末尾に追加)し、Reflect.set(obj, prop, newVal)で実際にオブジェクトへ値を書き込みます。値が変わっていなければ何もしない、つまり無駄な再描画を防ぐ工夫です。

return true;
      },
    });
  }

Proxyのsetトラップは、代入が成功したことを示すためにtrue(真偽値の「真」)を返す必要がある、というJavaScriptの仕様上の決まりです。falseを返すと、strictモードでは代入時にエラーが発生します。最後の});とfunctionの}は、それぞれnew Proxy(...)の引数オブジェクトを閉じる括弧、state関数自体を閉じる波括弧です。

← 2026.09.07 の号を読む