Svelte 5のrunesでコンポーネント設計はどう変わったか

Svelte 5でrunes構文が導入されてから、Alcogyの新規プロダクトはすべてrunesベースで実装しています。単なる書き方の変更ではなく、コンポーネント設計そのものの考え方が変わったと感じているので、実務での違いを整理しておきます。

何が変わったのか

Svelte 4までは、リアクティブな値はletで宣言し、算出値は$:ラベルで表現していました。Svelte 5では$state$derived$propsというrunesを使って、リアクティビティを明示的に宣言します。

<script lang="ts">
	// Svelte 4スタイル
	let count = 0;
	$: doubled = count * 2;
</script>
<script lang="ts">
	// Svelte 5 runesスタイル
	let count = $state(0);
	let doubled = $derived(count * 2);
</script>

見た目の差は小さいですが、$:は「このブロック内で参照している変数が変わったら再実行する」という暗黙のルールに依存していました。複雑なコンポーネントでは、どの変数が依存関係に含まれているか読み取りにくく、意図せず再計算が漏れることもありました。$derivedは依存関係の解決ロジックが明確になっており、挙動が予測しやすくなっています。

$propsによる受け渡しの明快さ

以前はexport letでpropsを宣言していましたが、$propsでは分割代入で受け取り、デフォルト値や型注釈もTypeScriptの記法にそのまま乗せられます。

<script lang="ts">
	interface Props {
		title: string;
		variant?: 'primary' | 'secondary';
		onSelect?: (id: string) => void;
	}

	let { title, variant = 'primary', onSelect }: Props = $props();
</script>

Alcogyでは業務システムのUIコンポーネントを$lib/components配下に切り出すことが多いのですが、$propsによって「このコンポーネントが何を受け取り、何を要求しているか」がインターフェース定義として1箇所にまとまるようになりました。以前はexport letの羅列とJSDocコメントで補っていた部分が、型定義として自然に表現できるようになったのは実務上の恩恵が大きい変化です。

storesからの移行で見えた恩恵

旧stores構文(writablederived)は、コンポーネントをまたいだ状態共有には便利でしたが、.svelteファイル内のローカルな状態管理にまで使うと、$storeという購読記法とオブジェクトの取り扱いが二重になり見通しが悪くなりがちでした。runesはコンポーネント内のローカル状態と、.svelte.tsファイルに切り出した共有状態のどちらも同じ$stateで表現できます。

// $lib/stores/session.svelte.ts
class SessionState {
	user = $state<{ id: string; name: string } | null>(null);

	get isLoggedIn() {
		return this.user !== null;
	}
}

export const session = new SessionState();

このように、クラスベースで状態をまとめておくと、MidletonやKilbegganのような業務システムでよくある「ログインユーザー情報をヘッダー・サイドバー・各ページで参照する」といったケースでも、ストアの購読解除やリアクティブ変数の再宣言を意識せずに済みます。Alcogyでは、共有が必要な状態は.svelte.tsのクラスに、ページ・コンポーネント固有の状態は$stateをそのままローカルに置く、という切り分けを基本方針にしています。