シグナルベースリアクティビティ入門 - Angular/Solid/Preact/Vue実装比較
シグナルベースリアクティビティ入門
Signalsは、フロントエンドフレームワークにおける新しい状態管理とリアクティビティのパラダイムです。Angular、Solid.js、Preact、Vueなど、多くの主要フレームワークが採用し、Reactコミュニティでも議論が活発化しています。本記事では、Signalsの概念から実装、パフォーマンスまで徹底解説します。
目次
- Signalsとは何か
- 従来の仮想DOM vs Signals
- Signalsの基本概念
- フレームワーク別実装
- パフォーマンス比較
- 実践パターン
- 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が注目されているのか
- パフォーマンス: 仮想DOMの差分計算が不要
- シンプル: 明示的な依存関係管理が不要
- 予測可能: データフローが明確
- 細粒度: 必要な部分だけを更新
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のような細粒度リアクティビティを検討していますが、以下の理由で慎重です。
- 既存のエコシステム: 大量の既存コードとの互換性
- 学習コスト: 新しいメンタルモデル
- 段階的な移行: 既存の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は、フロントエンド開発における重要なパラダイムシフトです。
メリット
- パフォーマンス: 仮想DOMの差分計算が不要で、高速
- シンプル: 自動的な依存関係追跡により、コードが簡潔
- 予測可能: データフローが明確で、デバッグしやすい
- 細粒度: 必要な部分だけを更新
デメリット
- 学習コスト: 新しいメンタルモデルの習得が必要
- エコシステム: Reactと比べるとライブラリが少ない
- 移行コスト: 既存のReactプロジェクトからの移行が困難
選択基準
- 新規プロジェクト: Solid.jsやAngular Signalsを検討
- 既存React: Preact SignalsやuseSyncExternalStoreを活用
- パフォーマンス重視: Solid.js
- エンタープライズ: Angular Signals
- 軽量: Preact Signals
Signalsは今後のフロントエンド開発において、ますます重要な位置を占めるでしょう。