シグナルベースリアクティビティ入門 - Angular/Solid/Preact/Vue実装比較


シグナルベースリアクティビティ入門

Signalsは、フロントエンドフレームワークにおける新しい状態管理とリアクティビティのパラダイムです。Angular、Solid.js、Preact、Vueなど、多くの主要フレームワークが採用し、Reactコミュニティでも議論が活発化しています。本記事では、Signalsの概念から実装、パフォーマンスまで徹底解説します。

目次

  1. Signalsとは何か
  2. 従来の仮想DOM vs Signals
  3. Signalsの基本概念
  4. フレームワーク別実装
  5. パフォーマンス比較
  6. 実践パターン
  7. ReactとSignalsの関係

1. Signalsとは何か

定義

Signalsは、**細粒度リアクティビティ(Fine-Grained Reactivity)**を実現するプリミティブです。状態の変更を自動的に追跡し、その状態に依存する計算やUIの更新を最小限の範囲で行います。

核となる3つの概念

// 1. Signal: 変更可能な値
const count = signal(0);

// 2. Computed: 派生値(自動的に再計算)
const doubled = computed(() => count.value * 2);

// 3. Effect: 副作用(自動的に再実行)
effect(() => {
  console.log(`Count: ${count.value}`);
});

// 値の更新
count.value = 1; // → "Count: 1" がログに出力

なぜSignalsが注目されているのか

  1. パフォーマンス: 仮想DOMの差分計算が不要
  2. シンプル: 明示的な依存関係管理が不要
  3. 予測可能: データフローが明確
  4. 細粒度: 必要な部分だけを更新

2. 従来の仮想DOM vs Signals

仮想DOMの仕組み

// React(仮想DOM)の例
function Counter() {
  const [count, setCount] = useState(0);

  // 1. stateが更新される
  // 2. コンポーネント全体が再レンダリング
  // 3. 新旧の仮想DOMを比較
  // 4. 差分を実DOMに反映

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {count * 2}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

問題点:

  • コンポーネント全体が再レンダリング
  • 仮想DOMの差分計算コスト
  • メモ化(useMemo、useCallback)の必要性

Signalsの仕組み

// Solid.js(Signals)の例
function Counter() {
  const [count, setCount] = createSignal(0);

  // 1. signalが更新される
  // 2. そのsignalを使っている部分だけが更新
  // 3. 実DOMに直接反映(仮想DOMなし)

  return (
    <div>
      <p>Count: {count()}</p>
      <p>Doubled: {count() * 2}</p>
      <button onClick={() => setCount(count() + 1)}>+1</button>
    </div>
  );
}

メリット:

  • 必要な部分だけが更新
  • 仮想DOMの差分計算が不要
  • メモ化が不要(自動的に最適化)

パフォーマンス比較図

仮想DOM(React):
State更新 → コンポーネント再実行 → 仮想DOM生成 → Diff計算 → DOM更新

           全てのJSX式を再評価

Signals(Solid.js):
Signal更新 → 依存する式のみ再評価 → DOM直接更新

         必要な部分のみ

3. Signalsの基本概念

Signal: 基本的な値

// 値の作成
const count = signal(0);

// 値の読み取り
console.log(count.value); // 0

// 値の更新
count.value = 1;

// 複雑なオブジェクトも扱える
const user = signal({
  name: 'Alice',
  age: 25
});

// ネストした値の更新
user.value = { ...user.value, age: 26 };

Computed: 派生値

// 基本的なcomputed
const count = signal(0);
const doubled = computed(() => count.value * 2);

console.log(doubled.value); // 0
count.value = 5;
console.log(doubled.value); // 10

// 複数のsignalに依存
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

console.log(fullName.value); // "John Doe"
firstName.value = 'Jane';
console.log(fullName.value); // "Jane Doe"

// 連鎖したcomputed
const count = signal(0);
const doubled = computed(() => count.value * 2);
const quadrupled = computed(() => doubled.value * 2);

count.value = 5;
console.log(quadrupled.value); // 20

Effect: 副作用

// 基本的なeffect
const count = signal(0);

effect(() => {
  console.log(`Current count: ${count.value}`);
}); // → "Current count: 0"

count.value = 1; // → "Current count: 1"

// クリーンアップ
effect((cleanup) => {
  const interval = setInterval(() => {
    console.log(count.value);
  }, 1000);

  cleanup(() => {
    clearInterval(interval);
  });
});

// 条件付きeffect
const shouldLog = signal(true);
const count = signal(0);

effect(() => {
  if (shouldLog.value) {
    console.log(count.value);
  }
});

4. フレームワーク別実装

Angular Signals

// Angular 16+
import { Component, signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <div>
      <p>Count: {{ count() }}</p>
      <p>Doubled: {{ doubled() }}</p>
      <button (click)="increment()">+1</button>
      <button (click)="decrement()">-1</button>
    </div>
  `,
})
export class CounterComponent {
  // Signal
  count = signal(0);

  // Computed signal
  doubled = computed(() => this.count() * 2);

  // Effect
  constructor() {
    effect(() => {
      console.log(`Count changed to: ${this.count()}`);
    });
  }

  // Methods
  increment() {
    this.count.update(value => value + 1);
  }

  decrement() {
    this.count.update(value => value - 1);
  }

  reset() {
    this.count.set(0);
  }
}

オブジェクトの扱い

// Angular
interface User {
  name: string;
  age: number;
  email: string;
}

@Component({
  selector: 'app-user-profile',
  template: `
    <div>
      <h2>{{ user().name }}</h2>
      <p>Age: {{ user().age }}</p>
      <p>Email: {{ user().email }}</p>
      <p>Adult: {{ isAdult() ? 'Yes' : 'No' }}</p>
      <button (click)="updateAge()">Birthday!</button>
    </div>
  `,
})
export class UserProfileComponent {
  user = signal<User>({
    name: 'Alice',
    age: 25,
    email: 'alice@example.com'
  });

  isAdult = computed(() => this.user().age >= 18);

  updateAge() {
    this.user.update(user => ({
      ...user,
      age: user.age + 1
    }));
  }

  updateEmail(newEmail: string) {
    this.user.mutate(user => {
      user.email = newEmail; // 直接変更(推奨されない)
    });
  }
}

Solid.js Signals

// Solid.js
import { createSignal, createEffect, createMemo } from 'solid-js';

function Counter() {
  // Signal
  const [count, setCount] = createSignal(0);

  // Computed (memo)
  const doubled = createMemo(() => count() * 2);

  // Effect
  createEffect(() => {
    console.log(`Count: ${count()}`);
  });

  return (
    <div>
      <p>Count: {count()}</p>
      <p>Doubled: {doubled()}</p>
      <button onClick={() => setCount(count() + 1)}>+1</button>
      <button onClick={() => setCount(count() - 1)}>-1</button>
    </div>
  );
}

リスト操作

// Solid.js
import { createSignal, For } from 'solid-js';

function TodoList() {
  const [todos, setTodos] = createSignal([
    { id: 1, text: 'Learn Solid', done: false },
    { id: 2, text: 'Build app', done: false }
  ]);

  const addTodo = (text: string) => {
    setTodos([
      ...todos(),
      { id: Date.now(), text, done: false }
    ]);
  };

  const toggleTodo = (id: number) => {
    setTodos(todos().map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  };

  const removeTodo = (id: number) => {
    setTodos(todos().filter(todo => todo.id !== id));
  };

  return (
    <div>
      <For each={todos()}>
        {(todo) => (
          <div>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleTodo(todo.id)}
            />
            <span
              style={{
                'text-decoration': todo.done ? 'line-through' : 'none'
              }}
            >
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>Delete</button>
          </div>
        )}
      </For>
    </div>
  );
}

Preact Signals

// Preact
import { signal, computed, effect } from '@preact/signals';

// グローバルなsignal
const count = signal(0);
const doubled = computed(() => count.value * 2);

function Counter() {
  // ローカルなsignal
  const localCount = signal(0);

  effect(() => {
    console.log(`Local count: ${localCount.value}`);
  });

  return (
    <div>
      <p>Global Count: {count}</p>
      <p>Global Doubled: {doubled}</p>
      <button onClick={() => count.value++}>+1 Global</button>

      <hr />

      <p>Local Count: {localCount}</p>
      <button onClick={() => localCount.value++}>+1 Local</button>
    </div>
  );
}

Reactコンポーネントでの使用

// Preact Signalsは、Reactでも使える!
import { signal, computed } from '@preact/signals-react';

const count = signal(0);
const doubled = computed(() => count.value * 2);

function Counter() {
  // signalの変更を自動的に追跡
  // useSyncExternalStoreを使って実装されている

  return (
    <div>
      <p>Count: {count.value}</p>
      <p>Doubled: {doubled.value}</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  );
}

Vue Composition API

<!-- Vue 3 -->
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue';

// Signal相当: ref
const count = ref(0);

// Computed相当: computed
const doubled = computed(() => count.value * 2);

// Effect相当: watchEffect
watchEffect(() => {
  console.log(`Count: ${count.value}`);
});

const increment = () => {
  count.value++;
};

const decrement = () => {
  count.value--;
};
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Doubled: {{ doubled }}</p>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
  </div>
</template>

複雑な状態管理

<script setup lang="ts">
import { ref, computed, reactive } from 'vue';

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

// リアクティブな配列
const todos = ref<Todo[]>([
  { id: 1, text: 'Learn Vue', done: false },
  { id: 2, text: 'Build app', done: false }
]);

// リアクティブなオブジェクト
const filter = ref<'all' | 'active' | 'completed'>('all');

// 派生値
const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(t => !t.done);
    case 'completed':
      return todos.value.filter(t => t.done);
    default:
      return todos.value;
  }
});

const stats = computed(() => ({
  total: todos.value.length,
  active: todos.value.filter(t => !t.done).length,
  completed: todos.value.filter(t => t.done).length
}));

// メソッド
const addTodo = (text: string) => {
  todos.value.push({
    id: Date.now(),
    text,
    done: false
  });
};

const toggleTodo = (id: number) => {
  const todo = todos.value.find(t => t.id === id);
  if (todo) {
    todo.done = !todo.done;
  }
};

const removeTodo = (id: number) => {
  const index = todos.value.findIndex(t => t.id === id);
  if (index > -1) {
    todos.value.splice(index, 1);
  }
};
</script>

<template>
  <div>
    <div>
      <button
        v-for="f in ['all', 'active', 'completed']"
        :key="f"
        @click="filter = f"
        :class="{ active: filter === f }"
      >
        {{ f }}
      </button>
    </div>

    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input
          type="checkbox"
          v-model="todo.done"
          @change="toggleTodo(todo.id)"
        />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">Delete</button>
      </li>
    </ul>

    <div>
      <p>Total: {{ stats.total }}</p>
      <p>Active: {{ stats.active }}</p>
      <p>Completed: {{ stats.completed }}</p>
    </div>
  </div>
</template>

5. パフォーマンス比較

ベンチマーク結果(JS Framework Benchmark)

操作: 10,000行の表を作成

React (useState):     ~180ms
Vue 3 (ref):          ~120ms
Solid.js (signal):    ~25ms
Preact (signals):     ~30ms
操作: 1,000行の一部を更新

React (useState):     ~45ms
Vue 3 (ref):          ~20ms
Solid.js (signal):    ~3ms
Preact (signals):     ~4ms

メモリ使用量

// React: 仮想DOMのオーバーヘッド
// 1コンポーネントあたり: ~200バイト(仮想DOM)

// Solid.js: 仮想DOMなし
// 1シグナルあたり: ~50バイト

// 1,000コンポーネントの場合
// React: ~200KB
// Solid.js: ~50KB

実際のアプリケーションでの比較

// ケーススタディ: 大量のリスト更新

// React版
function ReactList() {
  const [items, setItems] = useState(Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    value: i
  })));

  const updateItem = (id: number) => {
    setItems(items.map(item =>
      item.id === id ? { ...item, value: item.value + 1 } : item
    )); // 全体が再レンダリング
  };

  return (
    <div>
      {items.map(item => (
        <div key={item.id} onClick={() => updateItem(item.id)}>
          {item.value}
        </div>
      ))}
    </div>
  );
}

// Solid.js版
function SolidList() {
  const [items, setItems] = createSignal(Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    value: createSignal(i)
  })));

  const updateItem = (id: number) => {
    const item = items().find(i => i.id === id);
    if (item) {
      item.value[1](item.value[0]() + 1); // 該当の要素だけ更新
    }
  };

  return (
    <div>
      <For each={items()}>
        {(item) => (
          <div onClick={() => updateItem(item.id)}>
            {item.value[0]()}
          </div>
        )}
      </For>
    </div>
  );
}

// 結果:
// React: 1回の更新に ~50ms
// Solid.js: 1回の更新に ~0.5ms(100倍高速)

6. 実践パターン

グローバル状態管理

// store.ts - Preact Signalsでのグローバルストア
import { signal, computed } from '@preact/signals';

// State
export const user = signal<User | null>(null);
export const isLoading = signal(false);
export const error = signal<string | null>(null);

// Computed
export const isAuthenticated = computed(() => user.value !== null);
export const userName = computed(() => user.value?.name ?? 'Guest');

// Actions
export const login = async (email: string, password: string) => {
  isLoading.value = true;
  error.value = null;

  try {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) throw new Error('Login failed');

    user.value = await response.json();
  } catch (e) {
    error.value = e.message;
  } finally {
    isLoading.value = false;
  }
};

export const logout = () => {
  user.value = null;
};
// Component.tsx
import { user, isAuthenticated, login, logout } from './store';

function Header() {
  return (
    <header>
      {isAuthenticated.value ? (
        <>
          <span>Welcome, {user.value.name}!</span>
          <button onClick={logout}>Logout</button>
        </>
      ) : (
        <button onClick={() => login('user@example.com', 'password')}>
          Login
        </button>
      )}
    </header>
  );
}

非同期データフェッチ

// Solid.js
import { createResource, createSignal } from 'solid-js';

function UserProfile(props: { userId: number }) {
  // resourceは非同期データを扱うための特別なsignal
  const [user] = createResource(
    () => props.userId, // 依存する値
    async (userId) => {
      const response = await fetch(`/api/users/${userId}`);
      return response.json();
    }
  );

  return (
    <div>
      {user.loading && <p>Loading...</p>}
      {user.error && <p>Error: {user.error.message}</p>}
      {user() && (
        <div>
          <h2>{user().name}</h2>
          <p>{user().email}</p>
        </div>
      )}
    </div>
  );
}

フォーム処理

// Angular
@Component({
  selector: 'app-signup-form',
  template: `
    <form (ngSubmit)="handleSubmit()">
      <input
        type="email"
        [value]="email()"
        (input)="email.set($any($event.target).value)"
        placeholder="Email"
      />
      @if (emailError()) {
        <span class="error">{{ emailError() }}</span>
      }

      <input
        type="password"
        [value]="password()"
        (input)="password.set($any($event.target).value)"
        placeholder="Password"
      />
      @if (passwordError()) {
        <span class="error">{{ passwordError() }}</span>
      }

      <button type="submit" [disabled]="!isValid()">
        Sign Up
      </button>
    </form>
  `,
})
export class SignupFormComponent {
  email = signal('');
  password = signal('');

  emailError = computed(() => {
    const value = this.email();
    if (!value) return 'Email is required';
    if (!value.includes('@')) return 'Invalid email';
    return null;
  });

  passwordError = computed(() => {
    const value = this.password();
    if (!value) return 'Password is required';
    if (value.length < 8) return 'Password must be at least 8 characters';
    return null;
  });

  isValid = computed(() => {
    return !this.emailError() && !this.passwordError();
  });

  handleSubmit() {
    if (this.isValid()) {
      console.log('Submitting:', {
        email: this.email(),
        password: this.password()
      });
    }
  }
}

7. ReactとSignalsの関係

Reactの現状

Reactは仮想DOMベースであり、ネイティブなSignalsはサポートしていません。しかし、コミュニティでは議論が活発です。

React向けSignalsライブラリ

// @preact/signals-react
import { signal, computed } from '@preact/signals-react';

const count = signal(0);
const doubled = computed(() => count.value * 2);

function Counter() {
  // Preact SignalsをReactで使用
  // 自動的にuseっ同期されサブスクライブされる

  return (
    <div>
      <p>Count: {count.value}</p>
      <p>Doubled: {doubled.value}</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  );
}

ReactのSignals提案

Reactコアチームは、Signalsのような細粒度リアクティビティを検討していますが、以下の理由で慎重です。

  1. 既存のエコシステム: 大量の既存コードとの互換性
  2. 学習コスト: 新しいメンタルモデル
  3. 段階的な移行: 既存のAPIを維持しながら導入する必要性

Reactの代替アプローチ

// React Compiler(React Forget)
// コンパイラが自動的にメモ化を挿入

function Counter() {
  const [count, setCount] = useState(0);

  // React Compilerが自動的に最適化
  // 手動でuseMemo/useCallbackを書く必要がない

  const doubled = count * 2; // 自動メモ化

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

まとめ

Signalsは、フロントエンド開発における重要なパラダイムシフトです。

メリット

  1. パフォーマンス: 仮想DOMの差分計算が不要で、高速
  2. シンプル: 自動的な依存関係追跡により、コードが簡潔
  3. 予測可能: データフローが明確で、デバッグしやすい
  4. 細粒度: 必要な部分だけを更新

デメリット

  1. 学習コスト: 新しいメンタルモデルの習得が必要
  2. エコシステム: Reactと比べるとライブラリが少ない
  3. 移行コスト: 既存のReactプロジェクトからの移行が困難

選択基準

  • 新規プロジェクト: Solid.jsやAngular Signalsを検討
  • 既存React: Preact SignalsやuseSyncExternalStoreを活用
  • パフォーマンス重視: Solid.js
  • エンタープライズ: Angular Signals
  • 軽量: Preact Signals

Signalsは今後のフロントエンド開発において、ますます重要な位置を占めるでしょう。

参考リンク