Svelteのstateをコンポーネントの外に出す
一覧画面のように、無限スクロール・楽観的更新・メニューの開閉状態など複数のstateが絡む+page.svelteは、放っておくとあっという間に肥大化します。<script>ブロックがstate宣言とロジックで埋まってしまうと、テンプレート側で何が起きているかを追うコストが上がり、View本来の役割である「データをどう見せるか」に集中しづらくなります。Alcogyでは、こうしたページのstateとロジックを.svelte.tsファイルに切り出し、+page.svelte側はテンプレートに専念させる構成を基本にしています。
.svelte.tsファイルにstateを出す
Svelteは.svelte.js / .svelte.tsという拡張子のファイルでもrunes($stateや$derived)を使えます。公式ドキュメントでも「再利用可能なリアクティブロジックの作成や、アプリ全体でのリアクティブなstateの共有に便利」と説明されている通り、コンポーネント専用のstateであっても、このファイル形式に出しておくとロジックのテストや見通しがよくなります。
Alcogyでは、一覧画面のような複雑な状態管理が必要なページで、+page.svelteと同じディレクトリにindex.svelte.tsを置き、ファクトリ関数としてstateを公開する形にしています。
// index.svelte.ts
import type { PageData } from './$types';
type ListItem = { id: string; body: string; createdAt: string };
type FetchResult = { items: ListItem[]; hasMore: boolean };
export function createListState(getData: () => PageData) {
let extraPages = $state<ListItem[][]>([]);
let loading = $state(false);
let deletedIds = $state<Set<string>>(new Set());
const allItems = $derived(
[...getData().items, ...extraPages.flat()].filter((i) => !deletedIds.has(i.id))
);
async function loadMore() {
if (loading) return;
loading = true;
try {
const last = allItems.at(-1);
if (!last) return;
const res = await fetch(`/api/items?cursor=${last.createdAt}`);
const data = (await res.json()) as FetchResult;
extraPages = [...extraPages, data.items];
} finally {
loading = false;
}
}
async function deleteItem(id: string) {
deletedIds = new Set([...deletedIds, id]);
const res = await fetch(`/api/items/${id}`, { method: 'DELETE' });
if (!res.ok) {
const next = new Set(deletedIds);
next.delete(id);
deletedIds = next;
}
}
return {
get allItems() { return allItems; },
get loading() { return loading; },
loadMore,
deleteItem
};
}削除に失敗したらidの集合から取り除いてロールバックする、という楽観的更新のロジックまで含めて.svelte.ts側に閉じ込めているのがポイントです。+page.svelteは次のように、生成したstateをそのまま参照するだけになります。
<script lang="ts">
import { createListState } from './index.svelte.ts';
let { data } = $props();
const list = createListState(() => data);
</script>
{#each list.allItems as item (item.id)}
<li>{item.body}</li>
{/each}
{#if list.loading}
<p>読み込み中...</p>
{/if}<script>ブロックには生成したlistを扱う数行しか残らず、+page.svelteはテンプレートを組み立てることに専念できます。
データそのものではなく「取得する関数」を渡す
createListStateがdataではなくgetData: () => PageDataという関数を受け取っている点は重要です。JavaScriptは値渡しの言語なので、dataをそのまま渡すとその時点のスナップショットが渡るだけで、後からdataが更新されてもcreateListStateの中からは古い値しか見えません。公式ドキュメントの「Passing state into functions」でも、関数の中から常に最新の値を参照したい場合は値ではなく関数を渡す必要があると説明されています。SvelteKitはページ遷移時に+page.svelteを再利用し、data propだけを更新することがあるため、この「関数を渡す」パターンを踏まえていないと、ページ遷移後にstateが古いデータを参照し続けるという不具合につながります。
classではなく関数で定義している理由
Svelteの公式ドキュメントでは、共有されるリアクティブなstateはclassで管理することが推奨されています(Best practicesにも「use classes with $state fields to share reactivity between components」とあります)。しかし、Alcogyでは上記のようにclassを使わず、ファクトリ関数の形で定義しています。理由は$derivedをコンストラクタの中で使った場合の挙動にあります。
`$state`のドキュメントには、「class fieldとして使う」ことに加えて「コンストラクタ内でプロパティへの最初の代入として使う」ことも明示的に許可されています。
class Todo {
done = $state(false);
constructor(text: string) {
// コンストラクタ内での最初の代入も$stateとして機能する
this.text = $state(text);
}
}一方`$derived`のドキュメントには「class fieldとしてマークできる」としか書かれておらず、$stateのようにコンストラクタ内での代入を許可する記述がありません。実際に試すと、this.foo = $derived(...)のようにコンストラクタの中で代入した場合、class field宣言(foo = $derived(...))のときのようなgetter/setterへのコンパイラ変換が行われず、代入した時点の値がただのプロパティとして固定されてしまい、依存する値が変わっても再計算されません。コンストラクタの引数に応じて$derivedの中身を変えたいケース、たとえば今回のように外から受け取ったgetDataを使って$derivedを組み立てたいケースでは、この制約に引っかかります。
class field宣言の位置で完結する$derivedしか使わないのであればclassベースでも問題ありませんが、Alcogyのように「外から関数を受け取り、それを使って$derivedを組み立てる」パターンが多い場合は、素直に関数ベースで定義したほうが、ハマりどころを避けられて見通しも良くなります。