storeを使わずrunesだけでグローバル状態を作る——SvelteKitの多言語切り替えを3層で実装する

UIの言語をランタイムで切り替えたい場合、「現在の言語」はアプリ全体のどこからでも参照できる単一のstateであってほしいものです。Svelte 4までであればwritableストアで実装するところですが、Svelte 5のrunesは.svelte.js / .svelte.tsファイルのモジュールスコープでも使えるため、ストアを介さずに同じことができます。

// locale.svelte.ts
export type Locale = 'en' | 'ja';

let locale: Locale = $state('en');

export function getLocale(): Locale {
	return locale;
}

export function setLocale(l: Locale) {
	locale = l;
}

このファイルをインポートしたどのコンポーネントでもgetLocale()を呼べば現在の言語が読め、setLocale()を呼べば全コンポーネントに反映されます。ここでゲッター/セッター関数を挟んでいるのは単なる好みではなく必須の書き方です。ESモジュールのexportは「値を読むための生きたバインディング」であって、export let locale = $state('en')のように直接エクスポートしても、インポートした側からlocale = 'ja'のように代入することはできません(代入できるのは定義元のモジュール内だけ)。関数越しに読み書きすることで、この制約を回避しています。

このままではSSRで壊れる

ここまではwritableストアの代替として自然に見えますが、Cloudflare WorkersやNode上で動くSvelteKitのSSRでは、モジュールスコープの変数は「1リクエストにつき1つ」ではなく、そのWorker/プロセスが生きている間、複数のリクエストで共有されるという点が重要になります。もしsetLocale()がサーバー側の処理(load関数やフォームアクションなど)から呼ばれてしまうと、その変更は同じインスタンスが処理している他のユーザーのリクエストにまで影響しかねません。あるユーザーが言語を切り替えた瞬間に、たまたま同じインスタンスで処理されていた別のユーザーの画面まで言語が変わる、という事故につながります。

実務上は「setLocaleはボタンのクリックハンドラなど、ブラウザでしか実行されない場所からしか呼ばない」という運用でこの問題を避けられます。ただしこれはあくまで呼び出し側の規律に依存しており、locale = lという代入自体はコード上何もガードされていません。永続化(localStorage・Cookieへの書き込み)だけをbrowserチェックで囲んでも、代入そのものを防げているわけではない点には注意が必要です。

import { browser } from '$app/environment';

export function setLocale(l: Locale) {
	locale = l;
	if (browser) {
		localStorage.setItem('app_locale', l);
		document.cookie = `app_locale=${l}; path=/; max-age=31536000; SameSite=Lax`;
	}
}

SSRで最初から正しい言語を返すには、cookieとevent.localsを使う

モジュールスコープの$stateはサーバー起動時のデフォルト値(上の例では'en')からしか始まらないため、初回アクセス時にサーバーがユーザーの言語設定を知る手段としては使えません。ユーザーが日本語を選んでいるなら、初回のHTMLレスポンスの時点で日本語の文言を返したいところです。これはモジュールの$stateとは別に、リクエストごとに完結する仕組みで解決します。hooks.server.tsでCookieを読み、event.localsに載せるのが定石です。

// hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
	const cookieLocale = event.cookies.get('app_locale');
	event.locals.locale = cookieLocale === 'ja' ? 'ja' : 'en';
	return resolve(event);
};

サーバー側で翻訳済みの文言をレンダリングする処理は、モジュールのgetLocale()ではなく必ずevent.locals.locale(あるいはload関数を通じて渡された値)を参照します。こちらはリクエストごとに独立しているため、複数ユーザー間で状態が混ざる心配がありません。

初回描画のチラつきを防ぐインラインスクリプト

Cookie経由でSSRの文言は正しくできても、<html lang="...">のような属性をSvelteのコンポーネントマウント処理で設定していると、JSの実行(ハイドレーション)が終わるまで属性が反映されず、一瞬だけ既定言語で描画されてから切り替わるチラつきが起きえます。これを避けるため、app.htmlにSvelteKitのバンドルより先に実行される同期的な<script>を仕込み、localStorageから直接読んで属性を確定させます。

<!-- app.html -->
<script nonce="%sveltekit.nonce%">
	(function () {
		try {
			var lang = localStorage.getItem('app_locale') || 'en';
			document.documentElement.lang = lang;
		} catch (_) {}
	})();
</script>

3つの仕組みが役割を分担している

まとめると、「言語を切り替える」という一見単純な機能の裏には、担当が異なる3つの仕組みが存在します。

仕組み 役割 効くタイミング
モジュールスコープの$state クライアント側でのリアクティブな即時反映 JS実行後、ページ遷移をまたいでも保持
Cookie + event.locals サーバーが最初から正しい言語でHTMLを返す SSRレスポンス生成時
app.htmlのインラインスクリプト ハイドレーション前のチラつき防止 最初のペイント直前

どれか1つだけでは「クライアントでは切り替わるがSSRの初回表示は既定言語のまま」「SSRは正しいがJS実行までの一瞬だけチラつく」といった不整合が残ります。runesでグローバル状態を作れるようになったこと自体は便利ですが、SvelteKitのようにサーバーとクライアントの両方で同じモジュールが読み込まれる環境では、「どの層の問題を解決しているか」を意識して仕組みを重ねる必要があります。

ダーク/ライトのテーマ切り替えは2層で足りる

同じ考え方は、ダーク/ライトのテーマ切り替えにもそのまま使えます。実装もほぼ同型です。

// theme.svelte.ts
import { browser } from '$app/environment';

export type Theme = 'light' | 'dark' | 'system';

let theme: Theme = $state('system');

if (browser) {
	const saved = localStorage.getItem('theme') as Theme | null;
	if (saved) theme = saved;
	document.documentElement.setAttribute('data-theme', theme);
}

export function getTheme(): Theme {
	return theme;
}

export function setTheme(next: Theme) {
	theme = next;
	if (browser) {
		localStorage.setItem('theme', next);
		document.documentElement.setAttribute('data-theme', next);
	}
}

ただし、テーマにはlocaleでいう「Cookie + event.locals」の層が要りません。理由は、テーマがSSRの出力する文言には一切影響せず、data-theme属性によるCSSの出し分けにしか関わらないからです。localeは「どの言語の文字列をHTMLに埋め込むか」というサーバー側のレンダリング結果そのものを左右しますが、テーマは「同じHTMLをどんな見た目で描画するか」というクライアント側の表示の話でしかありません。app.htmlのインラインスクリプトが最初のペイント前にdata-theme属性を確定させてしまえば、SSRがどのテーマを前提にHTMLを生成したかは実質的に問題にならないため、2層(モジュールの$state + インラインスクリプト)だけで完結します。

同じ「モジュールスコープの$stateを使ったグローバル状態」というパターンでも、その値がSSRの出力内容そのものに影響するかどうかで、必要な層の数が変わってくる、というのがこの2つを見比べると分かりやすいところです。