コンテンツにスキップ

ライフサイクルとスコープ

synstate-dom は独自の所有者ツリーを持たず、synstate 本体のスコープを使います。スコープの実行中に同期的に作られた Observable はそのスコープに属し、束縛が行う購読もすべてそのスコープに属します。スコープを破棄すると、それらがまとめて破棄されます。

スコープを作るのは、単独で破棄できるところだけです。

作るもの 親スコープ 破棄されるとき
render なし render が返した関数を呼んだとき
If / MatchOptional / MatchResult の各分岐 If をマウントしたスコープ 分岐が変わったとき、親が破棄されたとき
For の各行 For をマウントしたスコープ 行のキーが消えたとき、親が破棄されたとき

コンポーネントごとにはスコープを作りません。コンポーネントは 1 回しか呼ばれず、その寿命はマウントされた分岐・行・ルートとちょうど同じだからです。

モジュールのトップレベル、つまりすべてのスコープの外で定義した状態はどこにも属さず、ページと同じだけ生き続けます。アプリのストアはそこに置きます。

synstate の派生は遅延評価ではありません。読まれていてもいなくても、親が変わるたびに計算されます。そこで、表示していないビューのために派生が計算されないよう、次の 2 つの書き方をおすすめします。

1 つのビューだけが読む派生は、ストアの横ではなく、そのコンポーネントの中で作りましょう。 そうすれば派生はコンポーネントがマウントされている間だけ存在し、作った時点から束縛という読み手がいることになります。

import { createState, map } from 'synstate';
import type { JSX } from 'synstate-dom';
import { Arr } from 'ts-data-forge';
// The store: module level, so it belongs to no scope and lives as long as the
// page.
export const [cart$, setCart] = createState<readonly number[]>([]);
export const CartTotal = (): JSX.Element => {
// Only this view reads the total, so it is created here. It belongs to the
// scope this component is mounted in and is disposed with it.
const total$ = cart$.pipe(map(Arr.sum));
return <p>{total$.pipe(map((t) => `Total: ${t}`))}</p>;
};

分岐で表示するものは、分岐の中で作りましょう(If の then がマウントするコンポーネントや、MatchOptional に渡す関数の中)。2 つとも先に作っておいてから選ぶ、という書き方は避けます。

import { createBooleanState, createState, map } from 'synstate';
import { constant, If, type JSX } from 'synstate-dom';
import { Arr } from 'ts-data-forge';
const [showStats$] = createBooleanState(false);
const [samples$] = createState<readonly number[]>([]);
export const Panel = (): JSX.Element => (
<If
cond={showStats$}
then={<Stats />}
else={<p>{constant('Stats are hidden')}</p>}
/>
);
const Stats = (): JSX.Element => {
// Created only while the branch is shown, and disposed when it closes.
const total$ = samples$.pipe(map(Arr.sum));
return <p>{total$.pipe(map((t) => `Total: ${t}`))}</p>;
};

「分岐の中」とは、上の Stats のように分岐がマウントするコンポーネントの中のことです。then に直接書いた JSX は書いた場所で If が選ぶ前に評価されるので、その中の pipe(map(...)) は外側のコンポーネントのスコープに作られ、分岐が隠れている間も計算を続けます。軽い束縛が数個ならそれで構いませんが、束縛が多い分岐や頻繁に更新される分岐は、独立したコンポーネントに移しましょう。

これでも残るのは、グローバルに定義した重い派生をたまにしか読まない場合です。synstate-dom は、読まれていない派生を休眠させることはしません。実際のアプリで問題になったら、そのときに改めて検討します。

ハンドラは、どのスコープの外でも実行されます。ハンドラが呼ばれる時点では、どの run も動いていません。そのため、ハンドラの中で作った Observable(a$.pipe(map(...)) など)はどのスコープにも属さず、行や分岐が消えた後も計算され続けてしまいます。そこで行った subscribe も解除されません。synstate-dom は実行時には warning を出しません。代わりに eslint-plugin-synstate の no-observable-in-handler ルールが、そうした Observable と subscribe を報告します。対象は、onClick などの props や、listenInScope・addEventListener に渡したリスナー、その中に書いたコールバック、そこから名前で呼ぶ同じファイルの関数の中で派生させた Observable と、そこで行った subscribe です。マウント時に取ったスコープの run の中は対象外です。ルールが追う範囲と見逃す場合は、プラグインの README にあります。

ハンドラでは状態に書き込むだけにして、その状態からの派生はマウント時に作っておきましょう。await の後など、後から実行する処理でどうしても Observable を作る必要があるなら、コンポーネントのマウント中に getCurrentScope() でスコープを取っておき、後の処理をそのスコープの run で包んでください。

コンポーネントの中の await の後も同じです。コンポーネントがスコープの中で実行されるのは return するまでなので、コンポーネントが始めた async 関数の中では、最初の await より後は現在のスコープが無い状態で実行されます。no-observable-in-handler は、そこで作った Observable と行った subscribe も報告します。最初の await より前のコードと、その前に取ったスコープの run の中の処理は対象外です。

コンポーネントと同じだけ続く副作用

Section titled “コンポーネントと同じだけ続く副作用”

コンポーネントは 1 回しか呼ばれません。ほかのライブラリなら effect で実行する処理は、コンポーネントの本体で始めて、そのスコープが破棄されるときに止めます。

  • synstate の source.subscribe(next) は、source の値を今の値から順に next に渡します。コンポーネントのマウント中に行った購読はコンポーネントのスコープに属し、そこで作った Observable と同じく、スコープが破棄されると解除されます。スコープより長く続けるときは untracked の中で購読します。
  • listenInScope(target, type, listener, options) は、window や document のように、コンポーネントが描く要素ではないものにイベントリスナーを登録し、スコープが破棄されると外します。コンポーネントが描く要素には onClick などの props を使ってください。
  • createAfterUpdate() は、処理を microtask で実行する関数を返します。実行されるのは、進行中の更新が終わった後です。その時点で、その更新に束縛された DOM は書き込み済みです。伝播も終わっているので、この処理からは状態に書き込めます。synstate が warning を出すのは、別の状態の伝播が終わる前に書き込んだときです。最初のマウントの後に処理を実行するのにも使えます。コンポーネントのノードが document に入るのは、それを含む記述全体がマウントされた後だからです。それまでにスコープが破棄されていた場合、その処理は捨てられます。
import { createState } from 'synstate';
import {
constant,
createAfterUpdate,
listenInScope,
type JSX,
} from 'synstate-dom';
export const SearchBox = (): JSX.Element => {
// Starts from the query saved last time.
const [query$, setQuery] = createState(
sessionStorage.getItem('search') ?? '',
);
// Saves the query whenever it changes. Subscribed while the component mounts,
// so the component's scope unsubscribes it: what an effect would do.
query$.subscribe((query) => {
sessionStorage.setItem('search', query);
});
// Removed with the scope, so a closed branch leaves no listener behind.
listenInScope(globalThis, 'keydown', (event) => {
if (event instanceof KeyboardEvent && event.key === 'Escape') {
setQuery('');
}
});
// The input is in the document only once the whole description is mounted, so
// it is focused after that.
const afterUpdate = createAfterUpdate();
let mut_input: HTMLInputElement | undefined = undefined;
afterUpdate(() => {
mut_input?.focus();
});
return (
<input
placeholder={constant('Search')}
ref={(element) => {
mut_input = element;
}}
value={query$}
onInput={(event) => {
setQuery(event.currentTarget.value);
}}
/>
);
};