T-CREATOR

Svelte URL ドリブン設計:検索パラメータとストアの同期パターン

Svelte URL ドリブン設計:検索パラメータとストアの同期パターン

Web アプリケーションを開発していて、「検索フィルタを適用した状態で URL をコピーして共有したい」「ブラウザの戻るボタンでフィルタの状態も戻したい」と思ったことはありませんか?

Svelte で構築する現代的な Web アプリケーションでは、URL の検索パラメータとアプリケーションの状態を同期させる「URL ドリブン設計」が非常に重要です。この記事では、Svelte のストアと URL 検索パラメータを効率的に同期させるパターンを、実践的なコード例とともに詳しく解説します。

初めて URL 駆動設計に取り組む方でも、記事を読み終える頃には自信を持って実装できるようになるでしょう。

背景

URL ドリブン設計とは

URL ドリブン設計とは、アプリケーションの状態を URL に反映させ、URL を「信頼できる唯一の情報源(Single Source of Truth)」として扱う設計手法です。

この手法により、ユーザーは特定の状態をブックマークしたり、URL を共有することで同じ画面状態を他の人と共有できます。また、ブラウザの履歴機能を活用して、戻る・進むボタンで状態を移動できるようになります。

Svelte におけるストアの役割

Svelte では、コンポーネント間で状態を共有するために「ストア」という仕組みを提供しています。ストアは writablereadablederived などの種類があり、リアクティブな状態管理を実現します。

しかし、ストアだけでは 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 をコピーして他のユーザーと同じ状態を共有できる
3SEO 最適化検索エンジンがパラメータ付き 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 つのステップで構成されます。

  1. URL からストアへの初期化: ページ読み込み時に URL パラメータを読み取り、ストアに設定
  2. ストアから URL への更新: ストアの値が変更されたときに URL を更新
  3. 無限ループの防止: フラグや条件分岐で循環参照を防ぐ

以下の図は、同期パターンの基本フローを示しています。

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 ドリブン設計を取り入れてみてください。最初は少し複雑に感じるかもしれませんが、一度実装すれば大きな価値を生み出すはずです。

関連リンク