1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React/Vueを超える?UIとStateを完全同期する新発想

Last updated at Posted at 2025-05-04

フロントエンドフレームワークの状態管理を劇的にシンプルにする新たな管理方法の提案

!!!注意!!!
この提案は、ほぼボイラープレートゼロで動作することが可能ですが、固有のフロントエンドフレームワークへの適用は考慮していません。

プロトタイプ

Liveサンプル

実行サンプル

順を追って説明します。

状態管理をクラスベースにする。

状態管理をクラスベースにし、状態をプロパティで管理します。
状態の更新は全て代入で行い、Proxyのsetトラップにより更新を検知し、更新トリガーを発行します。
状態の更新が複数ある場合、更新プロパティを蓄積しておいてまとめて実行するバッチ化処理などで効率化をはかります


class State {
  count = 0;
  increment() {
    this.count = this.count + 1;
  }
}

class StateHandler {
  set(target, prop, value, receiver) {
    try {
      return Reflect.set(target, prop, value, receiver);
    } finally {
      trigger(prop, value); // 更新トリガー
    }
  }
}

proxy = new Proxy(new State, new StateHandler);

階層構造を持つデータのアクセス

階層構造を持つ状態へのアクセスは、フルパスで行います。
フルパスを強制することで、唯一の通過点となりProxyで確実にトラップすることができます。
setトラップでパス単位の粒度で更新を検知することができます。
複雑な多段Proxyは不要になります。
フルパスへのアクセスはgetトラップで解析され、該当の要素のデータにアクセスできます。
※ここで示すgetトラップは概念的なもので実際の実装を示すものではありません。
getterでは、キャッシュ、パス解析のキャッシュ事前解析により高速化をはかります。


class State {
  user = {
    profile: {
      name: "Alice",
      email: "alice@domain",
    }
  }
  changeName(name) {
    this["user.profile.name"] = name; // trigger("user.profile.name")
  }
}

class StateHandler {
  get(target, prop, receiver) {
    const segments = prop.split(".");
    if (segments.length > 1) {
      // access by fullpath
      const lastName = segments.pop();
      const parentProp = segments.join(".");
      return this.get(target, parentProp, receiver)[lastName];
    } else {
      return Reflect.get(target, prop, receiver);
    }
  }
}

UIにもフルパスを強制します。

UIにも状態と同じようにフルパスで記述を行うようにします。
UIと状態が指すものがフルパスを介して一致するようになります。
つまり、状態のフルパスの変更を検知するとピンポイントでUIを更新できるようになります。
逆にUIの更新から状態のフルパスへ変更を通知することも可能になります。
結果的に、UIと状態の構造が一致するようになります。

{{ user.profile.name }}
{{ user.profile.email }}
class State {
  user = {
    profile: {
      name: "Alice",
      email: "alice@domain",
      licenses: [ "driving", "US CPA" ],
    }
  }
  changeName(name) {
    this["user.profile.name"] = name; // trigger("user.profile.name")-> UI:{{ user.profile.name }}
  }
}

function trigger(path, value) {
  // UIのノードを特定し更新を行う
}

UIの制限と構文

UIと状態の構造を一致させるため、ビューはシンプルに解析できるほうが良いので独自構文のほうが向いています。
※JSXは柔軟にHTMLを記述できてしまうので対応が取りにくく向いていません。
構文は繰り返し・条件分岐・埋め込み・属性バインドでおおよそのUIの構造表現が可能かと思います。
それぞれ、フルパスで指定できるようにします。

{{ for:user.profile.licenses }}
{{ endfor: }}

{{ if:user.isLogin }}
{{ else: }}
{{ endif: }}

{{ user.profile.name }}

<button data-bind="disabled:user.isLogout">

状態派生

状態クラスにgetterで派生状態を作ることができます。
実行時、getter内部で参照するフルパス("user.profile.name")を取得できるため、
依存関係を記憶し、参照するフルパス("user.profile.name")が更新された場合、
自動的に更新することができます。
UIでも普通のフルパスのプロパティと同様にアクセスすることができます。

{{ user.profile.name }} <!-- Alice -->
{{ user.profile.ucName }} <!-- ALICE -->
class State {
  user = {
    profile: {
      name: "Alice",
      email: "alice@domain",
      licenses: [ { name:"driving" }, { name:"US CPA" } ],
    }
  }
  get "user.profile.ucName"() {
    return this["user.profile.name"].toUpperCase();
  }
}

リスト

リストへのフルパスは、アスタリスクを使います
ループ内のインデックス変数はループのネストに応じて$1、$2と暗黙的に提供されます。
繰り返し・条件分岐・埋め込み・属性バインドいずれにも使用可能です。
ループコンテキストが同じ場合、パス内に$1を使わずアスタリスクを使います。
リストへの変更はイミュータブルに新しい配列を生成し、代入することで行います。
変更の際、リストの差分を検出しパフォーマンスを考慮します。

{{ for:user.profile.licenses }}
  <div>
    index={{ $1 }} 
    {{ user.profile.license.*.name }} 
    <button data-bind="onclick:delete">delete</button>
  </div>
{{ endfor: }}
class State {
  user = ...
  delete(e, $1) {
    this["user.profile.licenses"] = this["user.profile.licenses"].toSpliced($1, 1);
  }
}

リストの要素の状態派生

リスト要素の状態派生をgetterプロパティを使ってつくることができます。
ループコンテキストが同じであれば、アスタリスクを用いたプロパティ("user.profile.license.*.name")アクセスができるため
具体的なプロパティではなく抽象的なパスでの表現になり非常に宣言的な記述が行えます。
また通常の状態派生同様、依存トラッキングも可能です。
UI側のアクセスも通常のプロパティと変わらず記述できます。

{{ for:user.profile.licenses }}
  <div>index={{ $1 }} {{ user.profile.license.*.ucName }}</div>
{{ endfor: }}
class State {
  user = {
    profile: {
      name: "Alice",
      email: "alice@domain",
      licenses: [ { name:"driving" }, { name:"US CPA" } ],
    }
  }
  get "user.profile.license.*.ucName"() {
    return this["user.profile.license.*.name"].toUpperCase();
  }
}

保守性

UIも状態もフルパスで書くため、影響範囲の切り出しがパスの検索で容易に切り出せます。
UIも状態もフルパスで同じパスを検索すれば良いだけです。
依存がある場所もgetterに集中しているので管理も楽になります。

認知負荷の低減

UIも状態もフルパスで書き、同じ構造を指しているため、コンテキストの切り替えが少なくて済み
大幅に認知負荷を下げる効果があるものと思われます。

チーム開発

フルパスのためチームの開発者全員が同じもの指していること理解でき、
情報共有の齟齬をなくせる可能性があります。

デメリット

UIと構造を一致させるため適応できるアプリケーションを選びます。
ただ、getterなどの状態派生をうまく使えば、その制限も緩和できる可能性があります。

以上により、フルパスという共通言語を導入し、フルパスによりUIと状態を結び付ければ、多くのボイラープレートや状態フックが要らなくなり、非常にシンプルな状態管理を実践できるのです。

アプリケーションのサンプル例

<template>
  <div class="container">
    <div>
      <table class="table table-striped">
        <colgroup>
          <col class="col-md-3">
          <col class="col-md-3">
          <col class="col-md-2">
          <col class="col-md-2">
          <col class="col-md-2">
        </colgroup>
        <thead>
          <tr>
            <th class="text-center">State</th>
            <th class="text-center">Capital City</th>
            <th class="text-center">Population</th>
            <th class="text-center">Percent of Region's Population</th>
            <th class="text-center">Percent of Total Population</th>
          </tr>
        </thead>
        <tbody>
          {{ for:regions }}
            {{ for:regions.*.states }}
              <tr>
                <td class="text-center">{{ regions.*.states.*.name }}</td>
                <td class="text-center">{{ regions.*.states.*.capital }}</td>
                <td class="text-right" data-bind="
                  class.over : regions.*.states.*.population|ge,5000000;
                  class.under: regions.*.states.*.population|lt,1000000;
                ">{{ regions.*.states.*.population|locale }}</td>
                <td class="text-right">{{ regions.*.states.*.shareOfRegionPopulation|percent,2 }}</td>
                <td class="text-right">{{ regions.*.states.*.shareOfPopulation|percent,2 }}</td>
              </tr>
            {{ endfor: }}
            <tr class="summary">
              <td class="text-center" colspan="2">{{ regions.* }}</td>
              <td class="text-right">{{ regions.*.population|locale }}</td>
              <td></td>
              <td class="text-right">{{ regions.*.shareOfPopulation|percent,2 }}</td>
            </tr>
          {{ endfor: }}
        </tbody>
        <tfoot>
          <tr class="summary">
            <td class="text-center" colspan="2">Total</td>
            <td class="text-right">{{ population|locale }}</td>
            <td></td>
            <td></td>
          </tr>
        </tfoot>
      </table>
    </div>
  </div>
  
</template>  

<style>
  body {
    margin-left: 10px;
  }
  
  .over {
    color: red;
  }
  
  .under {
    color: blue;
  }
  
  tr.summary td {
    background-color: white;
    font-weight: bold;
  }
</style>
  
<script type="module">
import { allStates } from "states";

const summaryPopulation = (sum, population) => sum + population;

export default class {
  stateByRegion = Map.groupBy(allStates, state => state.region);
  regions = Array.from(new Set(allStates.map(state => state.region))).toSorted();

  get "regions.*.states"() {
    return this.stateByRegion.get(this["regions.*"]);
  }

  get "regions.*.states.*.shareOfRegionPopulation"() {
    return this["regions.*.states.*.population"] / this["regions.*.population"] * 100;
  }

  get "regions.*.states.*.shareOfPopulation"() {
    return this["regions.*.states.*.population"] / this.population * 100;
  }

  get "regions.*.population"() {
    return this.$getAll("regions.*.states.*.population", [ this.$1 ]).reduce(summaryPopulation, 0);
  }

  get "regions.*.shareOfPopulation"() {
    return this["regions.*.population"] / this.population * 100;
  }

  get population() {
    return this.$getAll("regions.*.population", []).reduce(summaryPopulation, 0);
  }

}
</script>
<template>
  <svg width="200" height="200">
    <g>
      <polygon data-bind="attr.points:points"></polygon>
      <circle cx="100" cy="100" r="80"></circle>
      {{ for:stats }}
        <text data-bind="
          attr.x:stats.*.labelPoint.x;
          attr.y:stats.*.labelPoint.y;
        ">{{ stats.*.label }}</text>
      {{ endfor: }}
    </g>
  </svg>
  {{ for:stats }}
  <div>
    <label>{{ stats.*.label }}</label>
    <input type="range" data-bind="value|number:stats.*.value" min="0" max="100">
    <span>{{ stats.*.value }}</span>
    <button data-bind="onclick:onRemove" class="remove">X</button>
  </div>
  {{ endfor: }}
  <form id="add">
    <input name="newlabel" data-bind="value:newLabel">
    <button data-bind="onclick:onAdd@preventDefault">Add a Stat</button>
  </form>
</template>

<style>
polygon {
  fill: #42b983;
  opacity: 0.75;
}

circle {
  fill: transparent;
  stroke: #999;
}

text {
  font-size: 10px;
  fill: #666;
}

label {
  display: inline-block;
  margin-left: 10px;
  width: 20px;
}

#raw {
  position: absolute;
  top: 0;
  left: 300px;
}
</style>

<script type="module">
export function valueToPoint(value, index, total) {
  const x = 0;
  const y = -value * 0.8;
  const angle = ((Math.PI * 2) / total) * index;
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  const tx = x * cos - y * sin + 100;
  const ty = x * sin + y * cos + 100;
  return {
    x: tx,
    y: ty
  };
}

export default class {
  newLabel = '';
  stats = [
    { label: 'A', value: 100 },
    { label: 'B', value: 100 },
    { label: 'C', value: 100 },
    { label: 'D', value: 100 },
    { label: 'E', value: 100 },
    { label: 'F', value: 100 }
  ];

  get "stats.json"() {
    return JSON.stringify(this.stats);
  }

  get "stats.*.labelPoint"() {
    return valueToPoint(100 + 10, this.$1, this.stats.length);
  }

  get "stats.*.point"() {
    return valueToPoint(this["stats.*.value"], this.$1, this.stats.length);
  }

  get points() {
    const points = this.$getAll("stats.*.point", []);
    return points.map(p => `${p.x},${p.y}`).join(" ")
  }

  onAdd(e) {
    if (!this.newLabel) return;
    this.stats = this.stats.concat({ label: this.newLabel, value: 100});
    this.newLabel = '';
  }

  onRemove(e, $1) {
    if (this.stats.length > 3) {
      this.stats = this.stats.toSpliced($1, 1);
    } else {
      alert("Can't delete more!");
    }
  }
}
</script>

記事へのフィードバック歓迎します。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?