コンテンツにスキップ

Signals の上の operator

Preact Signals もグリッチフリーです。そこで当然の疑問が出ます。Preact Signals を使い、その上に SynState の operator を足せばよいのではないか、と。純粋な変換はそのまま載ります。それが computed だからです。一方、状態を持つ operator、待つ operator、リクエストを始める operator は computed にできず、signal に書き込む effect になります。そう書くと、SynState の operator が保っている 2 つの性質を失います。どの読み手も整合したグラフを見ること、そして 1 回の発行が 1 つのイベントであることです。

このページの例は @preact/signals-core を使っており、ライブラリを動かす例はどれもそれに対して実行するテストです。

operator の種類例Preact Signals での書き方失うもの
純粋な同期変換map、derive、getKey、mapResultOkcomputedなし
既にある signal から選ぶ複数の状態の 1 つへの switchMapbranches[selector.value] を読む computedなし。依存は読んだものに付いていく
値から値へ状態を持ち越すscan、pairwise、skip、takesignal に書く effectイベント(同値の書き込みは捨てられ、batch は最後の書き込みだけを届ける)、先に作った読み手の整合性、遅延評価
時間debounce、throttle、audit、timerタイマーを置いて signal に書く effect遅延評価と、完了時に吐き出される保留中の値
値ごとのリクエストやストリームswitchMap、mergeMap、fromAbortablePromisesignal に書き、cleanup で中断する effect内側の値がすぐに出る場合(読み込み中の状態)の、先に作った読み手の整合性
完了take、takeUntil「終わった」を表す 2 つ目の signal下流すべてへの完了の伝搬

何かを失う行のうち 2 つを、次の 2 つの節で動かします。

非同期の段: 新しいクエリと古いリスト

Section titled “非同期の段: 新しいクエリと古いリスト”

非同期処理の比較の検索欄を、非同期の段だけに絞ったものです。クエリ、クエリごとのリクエスト、そしてクエリとリストがどのクエリの結果かを並べて表示する view があります。Preact Signals には switchMap がないので、リクエストはあのページと同じく searchState に書く effect になり、view はもう 1 つの effect になります。状態、リクエスト、view が表示する文字列は両方の版で共通です。

/** 検索欄の下のリストが表示しているもの。 */
export type SearchState = Readonly<{
status: 'loading' | 'success';
query: string;
}>;
/** 各版が返すもの: 入力する手段と、view の実行のすべて。 */
export type SearchView = Readonly<{
setQuery: (query: string) => void;
viewLog: readonly string[];
}>;
/** リクエストが応答するまでの時間。以下ではタイマーで代用する。 */
export const answerMs = 100;
export const showView = (query: string, state: SearchState): string =>
`${query} / ${state.status} for ${state.query}` as const;
export const createSearchView = (viewCreatedFirst: boolean): SearchView => {
const query = signal('a');
const searchState = signal<SearchState>({ status: 'loading', query: 'a' });
// ページに表示するもの: 入力欄のクエリと、リストがどのクエリの結果か。
const view = computed(() => showView(query.value, searchState.value));
const viewLog: string[] = [];
const watchView = (): void => {
effect(() => {
viewLog.push(view.value);
});
};
// switchMap((q) => request(q)) を effect で書いたもの。
const search = (): void => {
effect(() => {
const q = query.value;
searchState.value = { status: 'loading', query: q };
const answer = setTimeout(() => {
searchState.value = { status: 'success', query: q };
}, answerMs);
// 新しいクエリが実行中のリクエストを取り消す。
return () => {
clearTimeout(answer);
};
});
};
// 違うのは 2 つの effect を作る順番だけ。
if (viewCreatedFirst) {
watchView();
search();
} else {
search();
watchView();
}
return {
setQuery: (q) => {
query.value = q;
},
viewLog,
};
};

答え a が届いた後で setQuery('ab') を呼ぶと、view を先に作った場合は view が 2 回実行されます。1 回目は ab / success for a、つまり新しいクエリと古いクエリのリストの組で、2 回目が ab / loading for ab です。検索の effect より後に作ると、ab / loading for ab で 1 回だけ実行されます。書き込みは両方の effect に届き、ここでは effect は作った順に実行され、一方が他方に依存する順ではありません。間にある computed は読まれたときにはグリッチフリーですが、searchState に書く effect はその保証の外にあります。

SynState では、リクエストはグラフのノードです。

export const createSearchView = (): SearchView => {
const [query, setQuery] = createState('a');
// 新しいクエリは前の内側の Observable を完了させ、
// それがリクエストを取り消す。
const searchState = query.pipe(
switchMap((q) =>
timer(answerMs)
.pipe(map((): SearchState => ({ status: 'success', query: q })))
// クエリと同じ更新の中で出る
.pipe(
withInitialValue<SearchState>({
status: 'loading',
query: q,
}),
),
),
);
// ページに表示するもの: 入力欄のクエリと、リストがどのクエリの結果か。
const view = combine([query, searchState]).pipe(
map(([q, state]) => showView(q, state)),
);
const viewLog: string[] = [];
view.subscribe((v) => {
viewLog.push(v);
});
return { setQuery, viewLog };
};

switchMap は内側の Observable の最初の値(読み込み中の状態)を、クエリを変えたのと同じ更新の中で渡します。そのため両方の下にある view は ab / loading for ab で 1 回実行され、答えが届くと ab / success for ab でもう 1 回実行されます。(switchMap は query が作られたときに持っている 'a' も射影するので、Preact の effect と同じく a もリクエストします。)間違える順番はありません。ノードがどこで実行されるかは、作った時期ではなくグラフの中の深さで決まるからです。

下の入力欄に文字を打ってみてください。Preact Signals の view は最初は検索の effect より前に作られているので、キー入力のたびにその欄に赤い行が増えます。順番を切り替えてもう一度打つと、赤い行は出なくなります。

どのライブラリも同じ立場というわけではありません。Jotai の非同期 atom と Solid の createResource はそれぞれのグラフの派生ノードで、同じ view をそれらで書くと(unwrap、resource.loading)クエリ 1 回につき 1 回実行されます。リクエストを observable に書く reaction で表す MobX は、Preact Signals と同じ振る舞いになります。ライブラリごとの一覧は評価モデルの表にあります。

状態を持つ段: イベントが値として数えられる

Section titled “状態を持つ段: イベントが値として数えられる”

signal が持つのは現在の値です。持っている値と等しい書き込み(!==)は何も変えず誰にも通知せず、batch が届く effect は最後の書き込みの後に 1 回だけ実行されます。どちらも状態としては正しい振る舞いです。しかしイベントを数えたり組にしたりする operator にはイベントが 1 つ残らず必要で、signal の上では値しか受け取れません。

import { batch, effect, signal } from '@preact/signals-core';
// リスト項目のクリック。項目の id として送る。
const clicked = signal<string | undefined>(undefined);
const clickCount = signal(0);
// scan((n) => n + 1, 0) を effect で書いたもの。
effect(() => {
if (clicked.value !== undefined) {
clickCount.value = clickCount.peek() + 1;
}
});
clicked.value = 'A';
clicked.value = 'A'; // 同じ項目をもう一度: 同値なので捨てられる
batch(() => {
// 1 つの batch で処理した 2 回のクリック: effect は最後のものしか見ない
clicked.value = 'B';
clicked.value = 'C';
});
// 4 回のクリックが 2 回と数えられる

SynState では、emitter の発行は値に関わらずすべてイベントです。

import { createValueEmitter, scan } from 'synstate';
// リスト項目のクリック。項目の id として送る。
const [clicked, click] = createValueEmitter<string>();
const clickCount = clicked.pipe(scan((n) => n + 1, 0));
let mut_count = 0;
clickCount.subscribe((n) => {
mut_count = n;
});
click('A');
click('A'); // 同値かどうかに関わらず、発行はすべてイベント
click('B');
click('C');
// 4 回のクリックが 4 回と数えられる

createState は状態を持つものなので、signal と同じく同値を捨てます。イベントは同値を捨てない createValueEmitter か source で送ります。Preact Signals だけで書くなら、クリックのハンドラーが自分でクリックを数えるでしょう。それで動きますし、それこそが要点です。数える処理は書き込む場所に書くコードであって、どの signal の後にも置ける operator ではありません。

effect で書いた operator をグリッチフリーにするには、それぞれが、自分の書いたものを読むすべての effect より先に、つまり深さ順に実行される必要があります。Preact Signals はノードの深さも effect の実行順も公開していないので、operator は自前の深さを持ち、書き込みを自前のキューに通すことになります。それは SynState の深さ順のキューを Preact Signals の上に作り直すことで、その下では Preact Signals 自身の印付けと version の確認も払い続けます。イベントを数えるには、書き込みのたびに新しいオブジェクトで包み、batch の外で書く必要もあります。それは signal をイベント列として使うことです。

また effect は読んだものすべてを購読し、書いたものを誰かが読むかどうかに関わらず、変化のたびに再実行されます。そのため effect で書いた operator より下のグラフは SynState と同じく先行評価になり、ベンチマークで Preact Signals を速くしている遅延評価の computed と依存の追跡は、computed の部分にしか効きません。

どの読み手も整合したグラフを見ること、1 回の発行が 1 つのイベントであることは、Preact Signals に足りない operator ではありません。どちらもその下にあるモデルから来ています。届いたノードを深さ順に実行する更新と、発行を 1 つ残らず渡す emitter(createValueEmitter、source)です。Preact の signal が持つのは現在の値で、effect の実行順がグラフの中の位置で決まることはありません。effect で書いた operator はそのモデルを引き継ぐので、SynState の保証を取り戻すには、前の節のとおり SynState のモデルをその中に作り直すことになります。速さも持ち越せません。Preact Signals を速くしているものが効くのは、もともとそのまま載る部分だけだからです。SynState が提供するのは、operator と、それを正しく保つモデルの両方です。非同期の段も状態を持つ段も、すべての派生値と同じグリッチフリーなグラフのノードであり、scan のような状態を持つ段は、前にある emitter の発行をすべて受け取ります。

SynState を画面に出すおすすめの手段は synstate-dom です。Observable を DOM に直接束縛します。コンポーネントは 1 回しか実行されず、更新はグラフから、束縛された属性、プロパティ、テキストノードへ直接届きます。仮想 DOM もコンポーネントの再実行もありません。上の検索の段なら、クエリからページに表示するものまでが 1 つのグラフになり、Observable を別のものに変える箇所がどこにもありません。synstate-dom の紹介では、その検索とほか 2 つのデモをこのサイト上で動かしています。

Preact のアプリケーションには、synstate-preact-signals が境界で両者をつなぎます。Observable を Preact の signal に(toSignal)、signal を Observable に(fromSignal)変換するので、debounce、switchMap、リクエストといったパイプラインを SynState で書き、その結果を signal として Preact に渡せます。Preact Signals のデモを参照してください。ただしそこには継ぎ目が残ります。継ぎ目を越える値はすべて変換され、signal に入ったイベントは値になり、どの処理もどちらか一方に置かなければならず、非同期の段は SynState の側に置くことになります。synstate-dom なら継ぎ目はなく、非同期の段も含めて UI 全体が 1 つの SynState のグラフになります。