Svelte URL ドリブン設計:検索パラメータとストアの同期パターン
Web アプリケーションを開発していて、「検索フィルタを適用した状態で URL をコピーして共有したい」「ブラウザの戻るボタンでフィルタの状態も戻したい」と思ったことはありませんか?
Svelte で構築する現代的な Web アプリケーションでは、URL の検索パラメータとアプリケーションの状態を同期させる「URL ドリブン設計」が非常に重要です。この記事では、Svelte のストアと URL 検索パラメータを効率的に同期させるパターンを、実践的なコード例とともに詳しく解説します。
初めて URL 駆動設計に取り組む方でも、記事を読み終える頃には自信を持って実装できるようになるでしょう。
背景
URL ドリブン設計とは
URL ドリブン設計とは、アプリケーションの状態を URL に反映させ、URL を「信頼できる唯一の情報源(Single Source of Truth)」として扱う設計手法です。
この手法により、ユーザーは特定の状態をブックマークしたり、URL を共有することで同じ画面状態を他の人と共有できます。また、ブラウザの履歴機能を活用して、戻る・進むボタンで状態を移動できるようになります。
Svelte におけるストアの役割
Svelte では、コンポーネント間で状態を共有するために「ストア」という仕組みを提供しています。ストアは writable、readable、derived などの種類があり、リアクティブな状態管理を実現します。
しかし、ストアだけでは URL との同期が自動的に行われません。URL パラメータとストアの値を常に一致させるには、明示的な同期メカニズムが必要です。
以下の図は、URL ドリブン設計における基本的なデータフローを示しています。
mermaidflowchart TB
url["URL<br/>検索パラメータ"]
store["Svelte ストア"]
ui["UI コンポーネント"]
url -->|"パラメータ読み取り"| store
store -->|"リアクティブ更新"| ui
ui -->|"ユーザー操作"| store
store -->|"パラメータ書き込み"| url
style url fill:#e1f5ff
style store fill:#fff4e1
style ui fill:#f0ffe1
URL、ストア、UI の 3 つが循環的に連携することで、ユーザー操作が URL に反映され、URL の変更が UI に反映される仕組みが構築されます。
URL ドリブン設計のメリット
URL ドリブン設計を採用することで、以下のようなメリットが得られます。
| # | メリット | 説明 |
|---|---|---|
| 1 | ブックマーク可能性 | ユーザーが特定の検索条件やフィルタ状態をブックマークできる |
| 2 | 共有可能性 | URL をコピーして他のユーザーと同じ状態を共有できる |
| 3 | SEO 最適化 | 検索エンジンがパラメータ付き URL をインデックスできる |
| 4 | ブラウザ履歴との統合 | 戻る・進むボタンで状態を遷移できる |
| 5 | 初期状態の復元 | ページリロード時に状態が保持される |
課題
ストアと URL の同期における課題
Svelte のストアと URL 検索パラメータを同期させる際には、いくつかの技術的な課題が存在します。
まず、双方向同期の複雑さが挙げられます。URL が変更されたときにストアを更新し、ストアが変更されたときに URL を更新する必要がありますが、これが無限ループを引き起こす可能性があります。
次に、型の不一致も問題です。URL 検索パラメータは常に文字列として扱われますが、アプリケーションの状態は数値、真偽値、配列など様々な型を持ちます。この変換を適切に処理する必要があります。
さらに、初期化のタイミングも重要です。コンポーネントのマウント時に URL からパラメータを読み取り、ストアに反映させる順序を正しく制御しなければなりません。
以下の図は、同期における主な課題を示しています。
mermaidflowchart LR
subgraph challenges["同期の課題"]
loop["無限ループの<br/>リスク"]
type["型変換の<br/>必要性"]
timing["初期化<br/>タイミング"]
history["履歴管理の<br/>複雑さ"]
end
subgraph impacts["影響"]
perf["パフォーマンス<br/>低下"]
bug["予期しない<br/>動作"]
ux["UX の<br/>劣化"]
end
loop --> perf
type --> bug
timing --> bug
history --> ux
style challenges fill:#ffe1e1
style impacts fill:#fff4e1
これらの課題を適切に解決しないと、パフォーマンスの低下やバグ、ユーザー体験の劣化につながってしまいます。
具体的な問題シナリオ
実際の開発現場でよく遭遇する問題を見てみましょう。
シナリオ 1: 無限ループ
URL の変更を検知してストアを更新し、ストアの変更を検知して URL を更新すると、無限ループが発生します。
シナリオ 2: 型変換エラー
URL パラメータ ?page=2 を数値として扱いたいのに、文字列 "2" として扱われてしまい、比較演算やソートが正しく動作しません。
シナリオ 3: 初期値の不整合
ストアのデフォルト値が page: 1 なのに、URL に ?page=3 が含まれている場合、どちらを優先すべきか判断が必要です。
これらの問題を解決するために、次のセクションで具体的な解決策を紹介します。
解決策
基本的な同期パターン
URL パラメータとストアを同期させる基本パターンは、以下の 3 つのステップで構成されます。
- URL からストアへの初期化: ページ読み込み時に URL パラメータを読み取り、ストアに設定
- ストアから URL への更新: ストアの値が変更されたときに URL を更新
- 無限ループの防止: フラグや条件分岐で循環参照を防ぐ
以下の図は、同期パターンの基本フローを示しています。
mermaidsequenceDiagram
participant User as ユーザー
participant URL as URL
participant Store as ストア
participant UI as UI
Note over URL,Store: 初期化フェーズ
URL->>Store: パラメータ読み取り
Store->>UI: 初期値セット
Note over User,UI: ユーザー操作フェーズ
User->>UI: フィルタ変更
UI->>Store: 値を更新
Store->>URL: パラメータ書き込み
Store->>UI: リアクティブ更新
Note over URL,UI: 履歴操作フェーズ
User->>URL: 戻るボタン
URL->>Store: パラメータ読み取り
Store->>UI: 状態復元
このシーケンス図から、各フェーズでの役割分担が明確になります。初期化、ユーザー操作、履歴操作の 3 つのフェーズで適切に処理を行うことが重要です。
カスタムストアによる実装
Svelte のカスタムストアを使うことで、URL との同期ロジックをカプセル化できます。以下は、URL 同期機能を持つカスタムストアの実装例です。
まず、必要なモジュールをインポートします。
typescriptimport { writable, type Writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
次に、URL パラメータとストアを同期させるカスタムストア関数を定義します。
typescript/**
* URL 検索パラメータと同期するカスタムストアを作成
* @param key - URL パラメータのキー名
* @param defaultValue - デフォルト値
* @param parse - 文字列からアプリケーション型への変換関数
* @param serialize - アプリケーション型から文字列への変換関数
*/
export function createUrlStore<T>(
key: string,
defaultValue: T,
parse: (value: string | null) => T,
serialize: (value: T) => string
): Writable<T> {
ストアの内部状態を初期化します。URL パラメータが存在する場合はそれを使い、なければデフォルト値を使用します。
typescript// URL パラメータから初期値を取得
let initialValue = defaultValue;
if (typeof window !== 'undefined') {
const params = new URLSearchParams(
window.location.search
);
const urlValue = params.get(key);
if (urlValue !== null) {
initialValue = parse(urlValue);
}
}
基本となる writable ストアを作成します。
typescript// 基本ストアを作成
const store = writable<T>(initialValue);
// 更新中フラグで無限ループを防止
let isUpdating = false;
ストアの subscribe メソッドをラップし、値が変更されたときに URL を更新する処理を追加します。
typescript return {
subscribe: store.subscribe,
set: (value: T) => {
if (isUpdating) return;
isUpdating = true;
store.set(value);
// URL を更新
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
if (value === defaultValue) {
// デフォルト値の場合はパラメータを削除
params.delete(key);
} else {
// 値をシリアライズして設定
params.set(key, serialize(value));
}
新しい URL を構築し、ブラウザ履歴に追加します。
typescript const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
// replaceState で履歴を置き換え(戻るボタン対応)
window.history.replaceState({}, '', newUrl);
}
isUpdating = false;
},
update メソッドも同様に実装します。
typescript update: (fn: (value: T) => T) => {
store.update((currentValue) => {
const newValue = fn(currentValue);
if (isUpdating) return newValue;
// set メソッドを使って URL も更新
isUpdating = true;
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
if (newValue === defaultValue) {
params.delete(key);
} else {
params.set(key, serialize(newValue));
}
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
window.history.replaceState({}, '', newUrl);
}
isUpdating = false;
return newValue;
});
}
};
}
型変換ヘルパー関数
URL パラメータは常に文字列なので、適切な型に変換するヘルパー関数を用意します。
typescript/**
* 文字列を数値に変換(失敗時はデフォルト値を返す)
*/
export function parseNumber(
value: string | null,
defaultValue: number
): number {
if (value === null) return defaultValue;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
typescript/**
* 文字列を真偽値に変換
*/
export function parseBoolean(
value: string | null,
defaultValue: boolean
): boolean {
if (value === null) return defaultValue;
return value === 'true';
}
typescript/**
* 文字列を配列に変換(カンマ区切り)
*/
export function parseArray(
value: string | null,
defaultValue: string[]
): string[] {
if (value === null || value === '') return defaultValue;
return value.split(',').filter(Boolean);
}
これらのヘルパー関数を使うことで、安全かつ簡潔に型変換を行えます。
具体例
例 1: 検索フィルタの実装
実際の検索フィルタ機能を実装してみましょう。まず、必要なストアを定義します。
typescript// stores/searchFilters.ts
import {
createUrlStore,
parseNumber,
parseArray,
} from './urlStore';
/**
* 検索キーワードストア(文字列)
*/
export const searchKeyword = createUrlStore<string>(
'q', // URL パラメータのキー
'', // デフォルト値
(value) => value || '', // パース関数
(value) => value // シリアライズ関数
);
typescript/**
* カテゴリフィルタストア(配列)
*/
export const selectedCategories = createUrlStore<string[]>(
'categories',
[],
(value) => parseArray(value, []),
(value) => value.join(',')
);
typescript/**
* ページ番号ストア(数値)
*/
export const currentPage = createUrlStore<number>(
'page',
1,
(value) => parseNumber(value, 1),
(value) => value.toString()
);
次に、これらのストアを使用するコンポーネントを作成します。
svelte<!-- routes/search/+page.svelte -->
<script lang="ts">
import { searchKeyword, selectedCategories, currentPage } from '$lib/stores/searchFilters';
import { derived } from 'svelte/store';
// 複数のストアを組み合わせた派生ストア
const searchParams = derived(
[searchKeyword, selectedCategories, currentPage],
([$keyword, $categories, $page]) => ({
keyword: $keyword,
categories: $categories,
page: $page
})
);
</script>
検索キーワードの入力フォームを実装します。双方向バインディングを使うことで、入力値がストアに自動的に反映されます。
svelte<div class="search-container">
<label for="search">検索キーワード</label>
<input
id="search"
type="text"
bind:value={$searchKeyword}
placeholder="キーワードを入力..."
/>
</div>
カテゴリ選択のチェックボックスを実装します。
svelte<div class="category-filters">
<h3>カテゴリ</h3>
{#each categories as category}
<label>
<input
type="checkbox"
value={category.id}
checked={$selectedCategories.includes(category.id)}
on:change={(e) => {
// チェック状態に応じて配列を更新
if (e.currentTarget.checked) {
$selectedCategories = [...$selectedCategories, category.id];
} else {
$selectedCategories = $selectedCategories.filter(id => id !== category.id);
}
}}
/>
{category.name}
</label>
{/each}
</div>
ページネーションを実装します。
svelte<div class="pagination">
<button
disabled={$currentPage === 1}
on:click={() => $currentPage -= 1}
>
前へ
</button>
<span>ページ {$currentPage}</span>
<button
disabled={$currentPage >= totalPages}
on:click={() => $currentPage += 1}
>
次へ
</button>
</div>
このように実装することで、すべての操作が自動的に URL に反映され、URL の共有やブックマークが可能になります。
例 2: ソート機能の実装
次に、テーブルのソート機能を URL と同期させる例を見てみましょう。
typescript// stores/tableSort.ts
import { createUrlStore } from './urlStore';
// ソート対象のカラム
export const sortColumn = createUrlStore<string>(
'sort',
'name',
(value) => value || 'name',
(value) => value
);
typescript// ソート順序(昇順・降順)
export const sortOrder = createUrlStore<'asc' | 'desc'>(
'order',
'asc',
(value) => (value === 'desc' ? 'desc' : 'asc'),
(value) => value
);
テーブルヘッダーでソートを切り替えるコンポーネントを実装します。
svelte<!-- components/SortableTable.svelte -->
<script lang="ts">
import { sortColumn, sortOrder } from '$lib/stores/tableSort';
/**
* カラムのソートを切り替える
*/
function toggleSort(column: string) {
if ($sortColumn === column) {
// 同じカラムの場合は順序を反転
$sortOrder = $sortOrder === 'asc' ? 'desc' : 'asc';
} else {
// 別のカラムの場合は昇順にリセット
$sortColumn = column;
$sortOrder = 'asc';
}
}
</script>
svelte<table>
<thead>
<tr>
<th on:click={() => toggleSort('name')}>
名前
{#if $sortColumn === 'name'}
<span>{$sortOrder === 'asc' ? '▲' : '▼'}</span>
{/if}
</th>
<th on:click={() => toggleSort('date')}>
日付
{#if $sortColumn === 'date'}
<span>{$sortOrder === 'asc' ? '▲' : '▼'}</span>
{/if}
</th>
</tr>
</thead>
<!-- テーブルボディ -->
</table>
例 3: 複数タブの状態管理
タブ UI の選択状態を URL で管理する例です。
typescript// stores/tabState.ts
import { createUrlStore } from './urlStore';
export const activeTab = createUrlStore<string>(
'tab',
'overview',
(value) => value || 'overview',
(value) => value
);
タブコンポーネントを実装します。
svelte<!-- components/Tabs.svelte -->
<script lang="ts">
import { activeTab } from '$lib/stores/tabState';
const tabs = [
{ id: 'overview', label: '概要' },
{ id: 'details', label: '詳細' },
{ id: 'settings', label: '設定' }
];
</script>
<div class="tabs">
{#each tabs as tab}
<button
class:active={$activeTab === tab.id}
on:click={() => $activeTab = tab.id}
>
{tab.label}
</button>
{/each}
</div>
<div class="tab-content">
{#if $activeTab === 'overview'}
<div>概要コンテンツ</div>
{:else if $activeTab === 'details'}
<div>詳細コンテンツ</div>
{:else if $activeTab === 'settings'}
<div>設定コンテンツ</div>
{/if}
</div>
この実装により、タブの選択状態が URL に反映され、特定のタブを直接リンクで開くことができます。
複合的な活用例
以下の図は、これまでの例を組み合わせた実際のアプリケーションの状態管理フローを示しています。
mermaidflowchart TD
user["ユーザー操作"]
subgraph stores["ストアレイヤー"]
search["検索キーワード"]
category["カテゴリ"]
sort["ソート"]
page_num["ページ番号"]
end
subgraph url["URL レイヤー"]
params["?q=keyword&<br/>categories=A,B&<br/>sort=name&<br/>order=asc&<br/>page=2"]
end
subgraph ui["UI レイヤー"]
input["入力フォーム"]
filter["フィルタ"]
table["テーブル"]
pagination["ページネーション"]
end
user --> input
user --> filter
user --> table
user --> pagination
input --> search
filter --> category
table --> sort
pagination --> page_num
search --> params
category --> params
sort --> params
page_num --> params
params -.-> search
params -.-> category
params -.-> sort
params -.-> page_num
style stores fill:#fff4e1
style url fill:#e1f5ff
style ui fill:#f0ffe1
複数のストアが連携して URL を構成し、URL の変更が各ストアに反映されることで、一貫した状態管理が実現されます。
まとめ
この記事では、Svelte における URL ドリブン設計の実装方法を詳しく解説しました。
URL 検索パラメータとストアを同期させることで、ブックマーク可能性、共有可能性、SEO 最適化、ブラウザ履歴との統合、状態の永続化といった多くのメリットが得られます。
実装のポイントは以下の通りです。
カスタムストアの活用 Svelte のカスタムストア機能を使うことで、URL 同期ロジックをカプセル化し、再利用可能なコンポーネントを作成できます。
無限ループの防止 更新中フラグを使って、ストアと URL の相互更新が無限ループにならないよう制御することが重要です。
適切な型変換 URL パラメータは文字列なので、ヘルパー関数を使って安全に数値、真偽値、配列などに変換しましょう。
初期化の順序 ページ読み込み時に URL パラメータを優先してストアを初期化することで、共有された URL を正しく復元できます。
実際のアプリケーションでは、検索フィルタ、ソート、ページネーション、タブ選択など、さまざまな UI 要素に URL ドリブン設計を適用できます。これにより、ユーザー体験が大幅に向上し、よりプロフェッショナルな Web アプリケーションを構築できるでしょう。
今回紹介したパターンを基に、ぜひ皆さんのプロジェクトでも URL ドリブン設計を取り入れてみてください。最初は少し複雑に感じるかもしれませんが、一度実装すれば大きな価値を生み出すはずです。
関連リンク
articleSvelte URL ドリブン設計:検索パラメータとストアの同期パターン
articleSvelte ストア速見表:writable/derived/readable/custom の実用スニペット
articlesvelte-preprocess 導入ガイド:SCSS・PostCSS・TypeScript を安全に共存
articleSvelteKit アダプタ比較:Node/Vercel/Cloudflare/Netlify の速度と制約
articleSvelte ストアエラー「store is not a function」を解決:writable/derived の落とし穴
articleSvelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
articleCRDT × Zustand:Y.js/Automerge 連携でリアルタイム共同編集を設計
articleSvelte URL ドリブン設計:検索パラメータとストアの同期パターン
articleKubernetes で WebSocket:Ingress(NGINX/ALB) 設定とスティッキーセッションの実装手順
articleStorybook × Design Tokens 設計:Style Dictionary とテーマ切替の連携
articleWebRTC Simulcast 設計ベストプラクティス:レイヤ数・ターゲットビットレート・切替条件
articleSolidJS コンポーネント間通信チート:Context・イベント・store の選択早見
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来