UIライブラリ開発はオブジェクト指向の方が効率がいいかもしれない——Reactの関数コンポーネントと比較する

Reactに代表される関数コンポーネント中心のライブラリは、「コンポーネント自身は状態を持たず、外部の状態(useStateやストア)から都度描画する」ことを前提に設計されています。アプリケーション開発ではこの設計が威力を発揮しますが、UIライブラリそのもの——例えばノードをドラッグして線でつなぐビジュアルエディタのような、部品の種類が利用者側で増えていくタイプのライブラリ——を作る場面では、状態とふるまいを1つのインスタンスにまとめるオブジェクト指向の設計の方が効率的なことがあります。

Reactの関数コンポーネントは「種類を知っている前提」で書かれる

ノードベースのエディタをReactで実装すると、ノードの種類ごとに専用コンポーネントを用意し、描画時にその種類を判定して出し分ける形になりがちです。

// NodeBoard.tsx
type NodeData = {
  id: string;
  type: 'textbox' | 'display' | 'number' | 'condition' | 'calc';
  left: number;
  top: number;
  props: Record<string, unknown>;
};

function NodeBoard({ nodes }: { nodes: NodeData[] }) {
  return (
    <>
      {nodes.map((node) => {
        switch (node.type) {
          case 'textbox':
            return <TextBoxNode key={node.id} data={node} />;
          case 'display':
            return <DisplayNode key={node.id} data={node} />;
          case 'number':
            return <NumberNode key={node.id} data={node} />;
          case 'condition':
            return <ConditionNode key={node.id} data={node} />;
          case 'calc':
            return <CalcNode key={node.id} data={node} />;
        }
      })}
    </>
  );
}

新しいノード種別を1つ追加するたびに、NodeData['type']のユニオンと、このswitch文の両方に手を入れる必要があります。NodeBoardはエディタの中核ロジック(描画・配置・接続の管理)を担うコードであるにもかかわらず、個々のノードの種類が増減するたびに変更を迫られる状態になっています。

オブジェクト指向なら「種類を意識せず動かす」ことができる

同じものをクラス継承で設計すると、共通のふるまいを持つ基底クラスと、種類ごとの差分だけを書くサブクラスに分けられます。

// Node.ts
export default class Node {
  id: string;
  left = 30;
  top = 30;
  element: HTMLElement | null = null;
  props: NodeProps;

  constructor(props: NodeProps) {
    this.id = crypto.randomUUID();
    this.props = props;
  }

  move(dx: number, dy: number) {
    this.left += dx;
    this.top += dy;
    if (this.element) {
      this.element.style.left = `${this.left}px`;
      this.element.style.top = `${this.top}px`;
    }
  }

  remove() {
    this.element?.remove();
    this.element = null;
  }

  render(): HTMLElement {
    const el = document.createElement('div');
    el.classList.add('node');
    // ラベル・入出力の共通描画処理
    this.element = el;
    return el;
  }
}
// TextBoxNode.ts / ConditionNode.ts
export class TextBoxNode extends Node {
  constructor() {
    super({ label: 'Textbox', value: 'Hello' });
  }
}

export class ConditionNode extends Node {
  constructor() {
    super({ label: 'Condition', inputs: ['input1', 'input2'], outputs: ['true', 'false'] });
  }

  protected onInputChanged(value: unknown) {
    // 条件分岐に応じた出力の切り替えなど、この種類だけの処理
  }
}

エディタ全体を管理するクラス側は、Nodeという基底クラスの型だけを見て動きます。

// Board.ts
class Board {
  private nodes: Node[] = [];

  addNode(node: Node) {
    this.nodes.push(node);
    this.boardElement.appendChild(node.render());
  }

  moveAll(dx: number, dy: number) {
    for (const node of this.nodes) {
      node.move(dx, dy); // node.typeを一切見ていない
    }
  }
}

Boardのコードには、TextBoxNodeConditionNodeという具体的な名前は一切登場しません。新しい種類のノードを増やしたいときはNodeを継承した新しいクラスを1つ書くだけで済み、Board側のコードは変更不要です。

状態とふるまいが1つのインスタンスに同居する

この設計のもう1つの利点は、lefttopelementpropsといった、そのノード固有の状態がすべて同じインスタンスの中に閉じていることです。ノードを動かす処理はthis.leftを書き換えると同時に、同じメソッドの中でその場でDOMにも反映します。

move(dx: number, dy: number) {
  this.left += dx;
  this.top += dy;
  if (this.element) {
    this.element.style.left = `${this.left}px`;
    this.element.style.top = `${this.top}px`;
  }
}

関数コンポーネント中心の設計だと、座標は通常トップレベルの状態配列に正規化して持ち、更新はdispatchsetStateを経由してから再描画のサイクルに乗せる形になります。ノードの数が増えるほど、「どのノードの状態がどこにあるか」を追う配線が増えていきます。一方でクラス設計では、ノードのインスタンス自身が自分の状態と、その状態を変更する手段の両方を持っているため、配線を追加で用意する必要がありません。

継承によるふるまいの拡張は、入出力の伝播処理にも同じ形で表れます。

// IO.ts
class IO {
  value: unknown;

  update(v: unknown) {
    this.value = v;
  }
}

class OutputIO extends IO {
  private connections: IO[] = [];

  update(v: unknown) {
    super.update(v);
    for (const io of this.connections) {
      io.update(this.value); // 接続先への伝播はOutputIOだけの責務
    }
  }
}

基底クラスのupdate()は値を保持するだけの最小限の処理を担い、OutputIOsuper.update()で共通処理を呼んだ上で、自分固有の「接続先に伝播する」という差分だけを追加しています。テンプレートメソッド的にふるまいを積み増せるのも、継承ならではの書きやすさです。

この設計が効くのは「利用者側が種類を増やす」ライブラリだから

ここで重要なのは、オブジェクト指向がReactより常に優れているという話ではなく、向いている場面がはっきりしているということです。表示すべきコンポーネントの種類をアプリ開発者自身が把握・管理できるアプリケーションのUIであれば、Reactの「状態から都度描画する」モデルは、予測可能な再描画と少ない可変状態によって十分にメリットがあります。しかし、ライブラリの利用者が後から未知の種類のノードを追加していくことを前提にしたUIライブラリでは話が変わります。Boardのような中核ロジックのコードを一切変更せずに拡張できることが、継承とポリモーフィズムというクラス言語の標準機能だけで実現できるのは大きな利点です。

同じ拡張性をReact側で実現しようとすると、Record<string, ComponentType<NodeData>>のような登録用のレジストリをライブラリ側が用意し、利用者にそこへ追加してもらう設計を自前で作り込む必要があります。実現できないわけではありませんが、それは「クラス言語が標準で提供している動的ディスパッチを、レジストリという形で手動再実装している」状態に近く、素直さでは継承に一歩譲ります。

まとめ

UIライブラリを設計する際は、「利用者側の集合が固定されているか、後から広がっていくか」を見極めることが、パラダイム選択の実用的な判断基準になります。表示対象の種類が開発チーム側で完結して把握できるなら、Reactの関数コンポーネント+外部状態というモデルは十分に効率的です。一方で、ノードエディタのように利用者が種類を継ぎ足していくことを前提にしたUIライブラリを作るなら、状態とふるまいを1つのインスタンスにまとめ、基底クラスへの継承だけで拡張点を提供できるオブジェクト指向の設計の方が、コードの変更範囲を小さく保てる場面が多くあります。