シグナル
シグナルは、アプリケーションの状態を管理するためのリアクティブなプリミティブです。
シグナルをユニークにしているのは、状態の変化が可能な限り効率的な方法でコンポーネントとUIを自動的に更新することです。自動的な状態バインディングと依存関係の追跡により、シグナルは優れた人間工学と生産性を提供しながら、最も一般的な状態管理の落とし穴を排除します。
シグナルは、あらゆる規模のアプリケーションで効果を発揮し、小さなアプリケーションの開発をスピードアップする人間工学と、あらゆる規模のアプリケーションをデフォルトで高速化するパフォーマンス特性を備えています。
はじめに
JavaScriptにおける状態管理の多くの問題は、値が直接観測可能ではないため、特定の値の変化に反応することです。解決策は通常、値を変数に格納し、変更がないか継続的にチェックすることでこれを回避しますが、これは面倒で、パフォーマンスにも理想的ではありません。理想的には、変化したことを教えてくれる値を表現する方法が必要です。それがシグナルです。
基本的に、シグナルは値を保持する`.value`プロパティを持つオブジェクトです。これは重要な特性です。シグナルの値は変更できますが、シグナル自体は常に同じままです。
import { signal } from "@preact/signals";
const count = signal(0);
// Read a signal’s value by accessing .value:
console.log(count.value); // 0
// Update a signal’s value:
count.value += 1;
// The signal's value has changed:
console.log(count.value); // 1
REPLで実行Preactでは、シグナルがプロップまたはコンテキストとしてツリーを通して渡されるとき、シグナルへの参照のみを渡しています。コンポーネントはシグナルの値ではなくシグナルを参照しているので、シグナルはコンポーネントを再レンダリングせずに更新できます。これにより、高価なレンダリング作業をすべてスキップし、シグナルの`.value`プロパティに実際にアクセスするツリー内のコンポーネントにすぐにジャンプできます。
シグナルには、値にアクセスされたときと更新されたときを追跡するという、もう1つの重要な特性があります。Preactでは、コンポーネント内でシグナルの`.value`プロパティにアクセスすると、そのシグナルの値が変更されたときにコンポーネントが自動的に再レンダリングされます。
import { signal } from "@preact/signals";
// Create a signal that can be subscribed to:
const count = signal(0);
function Counter() {
// Accessing .value in a component automatically re-renders when it changes:
const value = count.value;
const increment = () => {
// A signal is updated by assigning to the `.value` property:
count.value++;
}
return (
<div>
<p>Count: {value}</p>
<button onClick={increment}>click me</button>
</div>
);
}
REPLで実行最後に、シグナルはPreactに深く統合されており、可能な限り最高の性能と人間工学を提供します。上記の例では、`count.value`にアクセスして`count`シグナルの現在の値を取得しましたが、これは不要です。代わりに、JSXで`count`シグナルを直接使用することで、Preactにすべての作業をさせることができます。
import { signal } from "@preact/signals";
const count = signal(0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}
REPLで実行インストール
シグナルは、プロジェクトに`@preact/signals`パッケージを追加することでインストールできます。
npm install @preact/signals
お好みのパッケージマネージャーでインストールしたら、アプリケーションにインポートする準備ができました。
使用例
現実的なシナリオでシグナルを使用してみましょう。タスクを追加および削除できるTODOリストアプリを作成します。まず、状態をモデル化することから始めます。最初に、`Array`で表すことができるTODOリストを保持するシグナルが必要です。
import { signal } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);
ユーザーが新しいTODOアイテムのテキストを入力できるようにするには、すぐに`<input>`要素に接続するもう1つのシグナルが必要です。現時点では、このシグナルを使用して、TODOアイテムをリストに追加する関数を作成できます。覚えておいてください、シグナルの`.value`プロパティに代入することで、シグナルの値を更新できます。
// We'll use this for our input later
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // Clear input value on add
}
:bulb:ヒント: シグナルに新しい値を代入した場合のみ、シグナルは更新されます。シグナルに代入する値が現在の値と等しい場合、更新されません。
const count = signal(0); count.value = 0; // does nothing - value is already 0 count.value = 1; // updates - value is different
`text`シグナルを更新して`addTodo()`を呼び出すと、`todos`シグナルに新しいアイテムが追加されます。これらの関数を直接呼び出すことで、このシナリオをシミュレートできます。まだユーザーインターフェースは必要ありません!
import { signal } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // Reset input value on add
}
// Check if our logic works
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}]
// Simulate adding a new todo
text.value = "Tidy up";
addTodo();
// Check that it added the new item and cleared the `text` signal:
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}, {text: "Tidy up"}]
console.log(text.value); // Logs: ""
REPLで実行最後に追加したい機能は、リストからTODOアイテムを削除する機能です。これには、指定されたTODOアイテムをtodos配列から削除する関数を追加します。
function removeTodo(todo) {
todos.value = todos.value.filter(t => t !== todo);
}
UIの構築
アプリケーションの状態をモデル化したので、ユーザーが対話できる優れたUIに接続する時間です。
function TodoList() {
const onInput = event => (text.value = event.currentTarget.value);
return (
<>
<input value={text.value} onInput={onInput} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.value.map(todo => (
<li>
{todo.text}{' '}
<button onClick={() => removeTodo(todo)}>❌</button>
</li>
))}
</ul>
</>
);
}
これで、完全に動作するTODOアプリができました!完全なアプリはこちらで試すことができます :tada
計算されたシグナルによる状態の導出
TODOアプリにもう1つの機能を追加しましょう。各TODOアイテムは完了としてチェックオフでき、ユーザーには完了したアイテムの数を表示します。そのためには、`computed(fn)`関数をインポートします。これにより、他のシグナルの値に基づいて計算される新しいシグナルを作成できます。返された計算されたシグナルは読み取り専用で、コールバック関数内でアクセスされたシグナルのいずれかが変更されると、その値が自動的に更新されます。
import { signal, computed } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries", completed: true },
{ text: "Walk the dog", completed: false },
]);
// create a signal computed from other signals
const completed = computed(() => {
// When `todos` changes, this re-runs automatically:
return todos.value.filter(todo => todo.completed).length;
});
// Logs: 1, because one todo is marked as being completed
console.log(completed.value);
REPLで実行単純なTODOリストアプリでは、多くの計算されたシグナルは必要ありませんが、より複雑なアプリでは、複数の場所に状態を複製しないように`computed()`に依存することがよくあります。
:bulb:ヒント: 可能な限り多くの状態を導出することで、状態が常に単一の情報源を持つことが保証されます。これはシグナルの重要な原則です。これにより、後でアプリケーションロジックに欠陥がある場合のデバッグがはるかに容易になります。心配する場所が少なくなります。
グローバルアプリケーション状態の管理
これまでのところ、コンポーネントツリーの外側にシグナルを作成するだけでした。TODOリストのような小さなアプリではこれで問題ありませんが、より大きく複雑なアプリでは、テストが難しくなる可能性があります。テストでは、通常、特定のシナリオを再現するためにアプリの状態の値を変更し、その状態をコンポーネントに渡し、レンダリングされたHTMLをアサートします。これを行うには、TODOリストの状態を関数に抽出できます。
function createAppState() {
const todos = signal([]);
const completed = computed(() => {
return todos.value.filter(todo => todo.completed).length
});
return { todos, completed }
}
:bulb:ヒント: ここでは、`addTodo()`関数と`removeTodo(todo)`関数を意識的に含めていません。データを変更する関数から分離することで、アプリケーションアーキテクチャの簡素化に役立つことがよくあります。詳細については、データ指向設計を参照してください。
レンダリング時にプロップとしてTODOアプリケーションの状態を渡すことができます。
const state = createAppState();
// ...later:
<TodoList state={state} />
これは、状態がグローバルであるため、TODOリストアプリで機能しますが、より大きなアプリでは、同じ状態の一部にアクセスする必要がある複数のコンポーネントを持つことになります。これには通常、共通の共有祖先コンポーネントに「状態を上に持ち上げる」ことが含まれます。プロップを介して各コンポーネントに状態を手動で渡すことを避けるために、状態をコンテキストに配置して、ツリー内の任意のコンポーネントがアクセスできるようにすることができます。これが一般的な外観の簡単な例です。
import { createContext } from "preact";
import { useContext } from "preact/hooks";
import { createAppState } from "./my-app-state";
const AppState = createContext();
render(
<AppState.Provider value={createAppState()}>
<App />
</AppState.Provider>
);
// ...later when you need access to your app state
function App() {
const state = useContext(AppState);
return <p>{state.completed}</p>;
}
コンテキストの動作の詳細については、コンテキストドキュメントを参照してください。
シグナルを使用したローカル状態
アプリケーション状態の大部分は、プロップとコンテキストを使用して渡されます。ただし、コンポーネントに固有の内部状態を持つ多くのシナリオがあります。この状態はアプリのグローバルビジネスロジックの一部として存在する理由がないため、必要なコンポーネントに限定する必要があります。このようなシナリオでは、`useSignal()`フックと`useComputed()`フックを使用して、コンポーネント内でシグナルと計算されたシグナルを作成することもできます。
import { useSignal, useComputed } from "@preact/signals";
function Counter() {
const count = useSignal(0);
const double = useComputed(() => count.value * 2);
return (
<div>
<p>{count} x 2 = {double}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}
これら2つのフックは、`signal()`と`computed()`をラップした薄いラッパーであり、コンポーネントが最初に実行されたときにシグナルを作成し、後続のレンダリングでは同じシグナルを単純に使用します。
:bulb: バックグラウンドでは、これが実装です。
function useSignal(value) { return useMemo(() => signal(value), []); }
高度なシグナルの使用
これまで説明したトピックは、開始するために必要なものです。次のセクションは、アプリケーションの状態を完全にシグナルを使用してモデル化することでさらに多くのメリットを得たい読者を対象としています。
コンポーネント外でのシグナルへの反応
コンポーネントツリーの外側でシグナルを操作するときに、値を積極的に読み取らない限り、計算されたシグナルは再計算されないことに気付いたかもしれません。これは、シグナルがデフォルトで遅延しているためです。値にアクセスされた場合にのみ、新しい値を計算します。
const count = signal(0);
const double = computed(() => count.value * 2);
// Despite updating the `count` signal on which the `double` signal depends,
// `double` does not yet update because nothing has used its value.
count.value = 1;
// Reading the value of `double` triggers it to be re-computed:
console.log(double.value); // Logs: 2
これは疑問を提起します。コンポーネントツリーの外側でシグナルを購読するにはどうすればよいでしょうか?シグナルの値が変更されるたびに何かをコンソールにログしたり、状態をLocalStorageに永続化したりしたい場合があります。
シグナルの変化に応じて任意のコードを実行するには、effect(fn)
を使用できます。 computed シグナルと同様に、effect はアクセスされたシグナルを追跡し、それらのシグナルが変化したときにコールバックを再実行します。 computed シグナルとは異なり、effect()
はシグナルを返しません。これは変更シーケンスの終了点です。
import { signal, computed, effect } from "@preact/signals-core";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);
// Logs name every time it changes:
effect(() => console.log(fullName.value));
// Logs: "Jane Doe"
// Updating `name` updates `fullName`, which triggers the effect again:
name.value = "John";
// Logs: "John Doe"
effect()
に提供されたコールバックからクリーンアップ関数を返すことで、次の更新が行われる前に実行できます。これにより、副作用を「クリーンアップ」し、コールバックの以降のトリガーに対する状態をリセットすることができます。
effect(() => {
Chat.connect(username.value)
return () => Chat.disconnect(username.value)
})
返された関数を使用することで、effect を破棄し、アクセスしたすべてのシグナルの購読を解除できます。
import { signal, effect } from "@preact/signals-core";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);
const dispose = effect(() => console.log(fullName.value));
// Logs: "Jane Doe"
// Destroy effect and subscriptions:
dispose();
// Updating `name` does not run the effect because it has been disposed.
// It also doesn't re-compute `fullName` now that nothing is observing it.
name.value = "John";
:bulb: ヒント: effect を広範囲に使用している場合は、クリーンアップすることを忘れないでください。そうしないと、アプリは必要以上にメモリを消費します。
購読せずにシグナルを読み取る
effect(fn)
の内部でシグナルに書き込む必要があるが、そのシグナルが変化したときに effect を再実行したくないという稀なケースでは、.peek()
を使用して、購読せずにシグナルの現在の値を取得できます。
const delta = signal(0);
const count = signal(0);
effect(() => {
// Update `count` without subscribing to `count`:
count.value = count.peek() + delta.value;
});
// Setting `delta` reruns the effect:
delta.value = 1;
// This won't rerun the effect because it didn't access `.value`:
count.value = 10;
:bulb: ヒント: シグナルを購読したくないシナリオは稀です。ほとんどの場合、effect がすべてのシグナルを購読することをお勧めします。本当に必要な場合にのみ
.peek()
を使用してください。
複数の更新を1つにまとめる
ToDo アプリで以前使用した addTodo()
関数を思い出してください。それがどのようなものだったかを復習しましょう。
const todos = signal([]);
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
}
この関数は、todos.value
を設定するときと text
の値を設定するときの2つの個別の更新をトリガーすることに注意してください。これは望ましくない場合があり、パフォーマンスやその他の理由から両方の更新を1つにまとめることを正当化する場合があります。 batch(fn)
関数を使用すると、複数の値の更新をコールバックの最後に1つの「コミット」にまとめることができます。
function addTodo() {
batch(() => {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
});
}
バッチ内で変更されたシグナルにアクセスすると、その更新された値が反映されます。バッチ内で他のシグナルによって無効化された computed シグナルにアクセスすると、その computed シグナルの最新の値を返すために必要な依存関係のみが再計算されます。他の無効化されたシグナルは影響を受けず、バッチコールバックの終了時にのみ更新されます。
import { signal, computed, effect, batch } from "@preact/signals-core";
const count = signal(0);
const double = computed(() => count.value * 2);
const triple = computed(() => count.value * 3);
effect(() => console.log(double.value, triple.value));
batch(() => {
// set `count`, invalidating `double` and `triple`:
count.value = 1;
// Despite being batched, `double` reflects the new computed value.
// However, `triple` will only update once the callback completes.
console.log(double.value); // Logs: 2
});
REPLで実行:bulb: ヒント: バッチはネストすることもできます。その場合、バッチ更新は、最上位のバッチコールバックが完了した後にのみフラッシュされます。
レンダリングの最適化
シグナルを使用すると、仮想 DOM レンダリングをバイパスし、シグナルの変更を DOM の変更に直接バインドできます。テキストの位置にある JSX にシグナルを渡すと、テキストとしてレンダリングされ、仮想 DOM の diff を行うことなく自動的にインプレースで更新されます。
const count = signal(0);
function Unoptimized() {
// Re-renders the component when `count` changes:
return <p>{count.value}</p>;
}
function Optimized() {
// Text automatically updates without re-rendering the component:
return <p>{count}</p>;
}
この最適化を有効にするには、.value
プロパティにアクセスする代わりに、シグナルを JSX に渡します。
同様のレンダリング最適化は、シグナルを DOM 要素のプロップとして渡す場合にもサポートされています。
API
このセクションは、シグナル API の概要です。シグナルの使用方法を既に知っており、使用可能なもののリマインダーが必要な人のためのクイックリファレンスとして作成されています。
signal(initialValue)
与えられた引数を初期値として持つ新しいシグナルを作成します。
const count = signal(0);
コンポーネント内でシグナルを作成する場合は、フックのバリアントを使用します: useSignal(initialValue)
。
返されたシグナルには、値の読み取りと書き込みを行うことができる .value
プロパティがあります。購読せずにシグナルから読み取るには、signal.peek()
を使用します。
computed(fn)
他のシグナルの値に基づいて計算される新しいシグナルを作成します。返された computed シグナルは読み取り専用であり、コールバック関数内でアクセスされたシグナルが変更されると、その値が自動的に更新されます。
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);
コンポーネント内で computed シグナルを作成する場合は、フックのバリアントを使用します: useComputed(fn)
。
effect(fn)
シグナルの変化に応じて任意のコードを実行するには、effect(fn)
を使用できます。 computed シグナルと同様に、effect はアクセスされたシグナルを追跡し、それらのシグナルが変化したときにコールバックを再実行します。コールバックが関数を返す場合、この関数は次の値の更新の前に実行されます。 computed シグナルとは異なり、effect()
はシグナルを返しません。これは変更シーケンスの終了点です。
const name = signal("Jane");
// Log to console when `name` changes:
effect(() => console.log('Hello', name.value));
// Logs: "Hello Jane"
name.value = "John";
// Logs: "Hello John"
コンポーネント内でシグナルの変化に対応する場合は、フックのバリアントを使用します: useSignalEffect(fn)
。
batch(fn)
batch(fn)
関数を使用すると、複数の値の更新を、提供されたコールバックの最後に1つの「コミット」にまとめることができます。バッチはネストでき、変更は最上位のバッチコールバックが完了した後にのみフラッシュされます。バッチ内で変更されたシグナルにアクセスすると、その更新された値が反映されます。
const name = signal("Jane");
const surname = signal("Doe");
// Combine both writes into one update
batch(() => {
name.value = "John";
surname.value = "Smith";
});