T-CREATOR

Svelte のカスタムイベント・バインディングを使いこなす

Svelte のカスタムイベント・バインディングを使いこなす

モダンな Web アプリケーション開発において、コンポーネント間の効率的な通信とデータ管理は非常に重要な要素です。Svelte は、そんな複雑な要求に対して、カスタムイベントと双方向バインディングという強力な仕組みを提供しています。

従来の JavaScript では、DOM イベントリスナーの管理やコンポーネント間の状態同期に多くのコードを書く必要がありました。しかし、Svelte のイベントシステムを理解すれば、より少ないコードで、より保守性の高いアプリケーションを構築できるでしょう。

本記事では、Svelte のカスタムイベントと双方向バインディングを基礎から応用まで段階的に学んでいきます。実際の開発現場で使える具体的な実装パターンを通じて、Svelte の真の力を体感していただけることと思います。

背景

従来の JavaScript イベント処理の課題

Web アプリケーションが複雑化するにつれて、従来の JavaScript によるイベント処理では様々な問題が浮き彫りになってきました。

最も大きな課題は、イベントリスナーの管理の煩雑さです。DOM 要素にイベントリスナーを追加したり削除したりする処理を手動で行うため、メモリリークが発生しやすく、デバッグも困難でした。

javascript// 従来のJavaScript - 手動でのイベント管理
const button = document.getElementById('myButton');

function handleClick(event) {
  console.log('ボタンがクリックされました');
}

// イベントリスナーの追加
button.addEventListener('click', handleClick);

// コンポーネントが破棄される際の手動クリーンアップ
function cleanup() {
  button.removeEventListener('click', handleClick);
}

さらに、コンポーネント間での状態共有も複雑でした。親子関係にないコンポーネント間でデータをやり取りする場合、グローバル変数や複雑なコールバック関数の連鎖が必要になることが多かったのです。

mermaidflowchart TD
  parent[親コンポーネント] -->|props| child1[子コンポーネント1]
  parent -->|props| child2[子コンポーネント2]
  child1 -->|callback| parent
  child2 -->|callback| parent
  parent -->|グローバル状態| global[グローバルストア]
  global -->|状態変更通知| parent

図解: 従来の JavaScript では、コンポーネント間通信のために props と callback の複雑な連鎖や、グローバル状態管理が必要でした。

Svelte が提供するイベントシステムの特徴

Svelte は、これらの課題を解決するために、宣言的で直感的なイベントシステムを提供しています。

第一の特徴は、自動的なイベントリスナー管理です。Svelte コンパイラが、コンポーネントのライフサイクルに合わせてイベントリスナーの追加と削除を自動的に行います。

typescript// Svelte - 自動的なイベント管理
<script lang="ts">
  function handleClick() {
    console.log('ボタンがクリックされました');
  }
</script>

<button on:click={handleClick}>
  クリックしてください
</button>

第二の特徴は、createEventDispatcher によるカスタムイベントの簡潔な実装です。親コンポーネントに向けてカスタムイベントを発火する仕組みが標準で用意されています。

typescript// 子コンポーネント
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  const dispatch = createEventDispatcher<{
    customEvent: { message: string };
  }>();

  function sendMessage() {
    dispatch('customEvent', { message: 'Hello from child!' });
  }
</script>

第三の特徴は、双方向バインディング(bind:)による状態の自動同期です。フォーム要素とコンポーネントの状態を簡単に同期できます。

mermaidflowchart LR
  input[入力フィールド] <-->|bind:value| state[コンポーネント状態]
  state <-->|リアクティブ更新| ui[UI表示]

図解: Svelte の bind:構文により、入力値とコンポーネント状態が自動的に同期され、UI も即座に更新されます。

カスタムイベントとバインディングの関係性

Svelte におけるカスタムイベントと双方向バインディングは、互いに補完し合う関係にあります。

カスタムイベントは主に「一方向の通信」を担当します。子コンポーネントから親コンポーネントへ、何らかのアクションが発生したことを通知する際に使用します。

一方、双方向バインディングは「状態の同期」を担当します。親子間でデータを共有し、どちらかで変更が発生した際に自動的に同期を取る仕組みです。

この 2 つを組み合わせることで、複雑なコンポーネント間通信を、宣言的かつ直感的に実装できるようになります。例えば、カスタム入力コンポーネントでは、bind で値を同期しつつ、特定のイベント(フォーカス、バリデーションエラーなど)はカスタムイベントで親に通知する、といった使い分けが可能です。

課題

複雑なコンポーネント間通信の実現

現代の Web アプリケーションでは、単純な親子関係を超えた複雑なコンポーネント間通信が求められます。

例えば、ショッピングカートアプリケーションを考えてみましょう。商品リストコンポーネント、商品詳細モーダル、カートサイドバー、ヘッダーのカート件数表示など、複数のコンポーネントが相互に連携する必要があります。

従来のアプローチでは、これらのコンポーネント間でデータを同期するために、複雑な状態管理ライブラリを導入したり、プロップスのバケツリレーが発生したりしていました。

typescript// 課題のあるパターン - プロップスのバケツリレー
// 祖先 → 親 → 子 → 孫 と、使わないプロップスを渡し続ける
<GrandParent cartItems={cartItems}>
  <Parent cartItems={cartItems}>
    <Child cartItems={cartItems}>
      <GrandChild cartItems={cartItems} />
    </Child>
  </Parent>
</GrandParent>

Svelte のカスタムイベントシステムでは、このような課題をより簡潔に解決できますが、設計時に適切な通信パターンを選択する必要があります。

双方向データバインディングの効率的な管理

フォームが多用されるアプリケーションでは、ユーザー入力の管理が複雑になりがちです。

特に、以下のような要件を満たす必要がある場合、従来のアプローチでは多くのボイラープレートコードが必要でした:

  • リアルタイムバリデーション
  • 入力値の自動整形(数値のカンマ区切り、郵便番号のハイフン挿入など)
  • 複数の入力フィールド間の連動(住所の自動補完、計算フィールドなど)
typescript// 課題のあるパターン - 手動での状態管理
let email = '';
let emailError = '';

function handleEmailChange(event) {
  email = event.target.value;

  // バリデーション処理
  if (!email.includes('@')) {
    emailError = 'メールアドレスの形式が正しくありません';
  } else {
    emailError = '';
  }
}

// 各フィールドごとに同様の処理が必要

Svelte の双方向バインディングを活用すれば、このような煩雑な処理を大幅に簡略化できますが、パフォーマンスやメンテナンス性を考慮した実装パターンの理解が重要です。

型安全なイベント処理の実装

TypeScript を使用した Svelte プロジェクトでは、カスタムイベントの型安全性を確保することが重要な課題となります。

特に、以下のような場面で型安全性が不足すると、ランタイムエラーの原因となります:

  • カスタムイベントのペイロード型定義
  • イベントハンドラーの引数型チェック
  • 親子コンポーネント間でのイベント契約の明確化
typescript// 型安全性に課題のあるパターン
const dispatch = createEventDispatcher();

// イベントペイロードの型が不明
dispatch('userAction', someData);

// 親コンポーネントでも型チェックができない
<ChildComponent on:userAction={handleUserAction} />;

TypeScript と Svelte の組み合わせで、完全に型安全なカスタムイベントシステムを構築するには、適切な型定義の方法を理解する必要があります。

この課題を解決することで、開発時のエラー検出率が向上し、リファクタリング時の安全性も大幅に高まります。

解決策

カスタムイベントの基本的な作成と発火

Svelte でカスタムイベントを実装する最も基本的な方法は、createEventDispatcherを使用することです。

まず、子コンポーネントでイベントディスパッチャーを作成します:

typescript<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  // イベントの型を定義
  type Events = {
    increment: { value: number };
    decrement: { value: number };
    reset: null;
  };

  const dispatch = createEventDispatcher<Events>();

  let count = 0;
</script>

次に、イベントを発火する関数を実装します:

typescript<script lang="ts">
  // ... 上記の続き

  function handleIncrement() {
    count += 1;
    dispatch('increment', { value: count });
  }

  function handleDecrement() {
    count -= 1;
    dispatch('decrement', { value: count });
  }

  function handleReset() {
    count = 0;
    dispatch('reset', null);
  }
</script>

<div class="counter">
  <p>現在の値: {count}</p>
  <button on:click={handleIncrement}>+1</button>
  <button on:click={handleDecrement}>-1</button>
  <button on:click={handleReset}>リセット</button>
</div>

親コンポーネントでは、これらのカスタムイベントを受け取って処理します:

typescript<script lang="ts">
  import Counter from './Counter.svelte';

  let totalChanges = 0;
  let lastAction = '';

  function handleCounterEvent(event) {
    totalChanges += 1;
    lastAction = `${event.type}: ${JSON.stringify(event.detail)}`;
  }
</script>

<Counter
  on:increment={handleCounterEvent}
  on:decrement={handleCounterEvent}
  on:reset={handleCounterEvent}
/>

<p>変更回数: {totalChanges}</p>
<p>最後のアクション: {lastAction}</p>

双方向バインディング(bind:)の活用

Svelte のbind:ディレクティブを使用すると、フォーム要素とコンポーネントの状態を簡単に同期できます。

基本的なフォーム要素のバインディング

typescript<script lang="ts">
  let userName = '';
  let userAge = 0;
  let isSubscribed = false;
  let selectedPlan = 'basic';

  // リアクティブステートメントで自動計算
  $: isValidForm = userName.length > 0 && userAge >= 18;
</script>

<form>
  <!-- テキスト入力 -->
  <label>
    ユーザー名:
    <input type="text" bind:value={userName} />
  </label>

  <!-- 数値入力 -->
  <label>
    年齢:
    <input type="number" bind:value={userAge} />
  </label>

  <!-- チェックボックス -->
  <label>
    <input type="checkbox" bind:checked={isSubscribed} />
    メルマガを購読する
  </label>

  <!-- セレクトボックス -->
  <label>
    プラン:
    <select bind:value={selectedPlan}>
      <option value="basic">ベーシック</option>
      <option value="premium">プレミアム</option>
      <option value="enterprise">エンタープライズ</option>
    </select>
  </label>

  <button type="submit" disabled={!isValidForm}>
    登録
  </button>
</form>

カスタムコンポーネントでのバインディング

カスタムコンポーネントでも双方向バインディングを実装できます:

typescript<!-- NumberInput.svelte -->
<script lang="ts">
  export let value = 0;
  export let min = 0;
  export let max = 100;
  export let step = 1;

  // 入力値の検証と正規化
  function normalizeValue(inputValue: number): number {
    return Math.min(Math.max(inputValue, min), max);
  }

  // 入力変更時の処理
  function handleInput(event: Event) {
    const target = event.target as HTMLInputElement;
    const numValue = parseFloat(target.value) || 0;
    value = normalizeValue(numValue);
  }
</script>

<div class="number-input">
  <button
    on:click={() => value = normalizeValue(value - step)}
    disabled={value <= min}
  >
    -
  </button>

  <input
    type="number"
    {min}
    {max}
    {step}
    {value}
    on:input={handleInput}
  />

  <button
    on:click={() => value = normalizeValue(value + step)}
    disabled={value >= max}
  >
    +
  </button>
</div>

このカスタムコンポーネントは親から以下のように使用できます:

typescript<script lang="ts">
  import NumberInput from './NumberInput.svelte';

  let quantity = 1;
  let price = 1000;

  $: totalPrice = quantity * price;
</script>

<NumberInput bind:value={quantity} min={1} max={10} />
<NumberInput bind:value={price} min={100} max={10000} step={100} />

<p>合計金額: {totalPrice.toLocaleString()}円</p>

イベントフォワーディングとモディファイア

イベントフォワーディング

コンポーネントが受け取ったイベントを親コンポーネントに転送したい場合、on:eventディレクティブを値なしで使用します:

typescript<!-- Button.svelte -->
<script lang="ts">
  export let variant: 'primary' | 'secondary' = 'primary';
  export let disabled = false;
</script>

<!-- クリックイベントを親に転送 -->
<button
  class="btn btn-{variant}"
  {disabled}
  on:click
  on:focus
  on:blur
>
  <slot />
</button>

イベントモディファイア

Svelte はイベント処理を簡潔にするための様々なモディファイアを提供しています:

typescript<script lang="ts">
  let clickCount = 0;
  let message = '';

  function handleSingleClick() {
    clickCount += 1;
  }

  function handleOnceClick() {
    message = 'このイベントは一度だけ発火します';
  }

  function handlePreventDefault(event: Event) {
    // preventDefault()は自動的に呼ばれる
    message = 'デフォルト動作がキャンセルされました';
  }
</script>

<!-- 一度だけ実行されるイベント -->
<button on:click|once={handleOnceClick}>
  一度だけクリック
</button>

<!-- デフォルト動作を防ぐ -->
<a href="https://example.com" on:click|preventDefault={handlePreventDefault}>
  リンク(ページ遷移しない)
</a>

<!-- イベントの伝播を停止 -->
<div on:click={() => message = '親要素のクリック'}>
  <button on:click|stopPropagation={handleSingleClick}>
    伝播を停止するボタン
  </button>
</div>

<!-- 受動的なイベントリスナー(パフォーマンス向上) -->
<div on:touchstart|passive={() => console.log('タッチ開始')}>
  タッチ対応要素
</div>

<!-- 自分自身が対象の場合のみ実行 -->
<div on:click|self={() => message = 'div自体がクリックされました'}>
  <p>この段落をクリックしてもイベントは発火しません</p>
</div>

複数のモディファイアを組み合わせることも可能です:

typescript<!-- 一度だけ実行され、デフォルト動作も防ぐ -->
<form on:submit|once|preventDefault={handleFormSubmit}>
  <button type="submit">送信(一度だけ)</button>
</form>

これらの解決策を組み合わせることで、Svelte の強力なイベントシステムを最大限に活用できます。

具体例

親子コンポーネント間のカスタムイベント通信

実際のアプリケーションでよく使われる、TODO リストを例にカスタムイベント通信を実装してみましょう。

まず、個別の TODO アイテムを表示するコンポーネントを作成します:

typescript<!-- TodoItem.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let todo: {
    id: string;
    text: string;
    completed: boolean;
  };

  type Events = {
    toggle: { id: string };
    delete: { id: string };
    edit: { id: string; newText: string };
  };

  const dispatch = createEventDispatcher<Events>();

  let isEditing = false;
  let editText = todo.text;

  function handleToggle() {
    dispatch('toggle', { id: todo.id });
  }

  function handleDelete() {
    dispatch('delete', { id: todo.id });
  }
</script>

編集機能とイベント発火の実装:

typescript<!-- TodoItem.svelte の続き -->
<script lang="ts">
  // ... 上記の続き

  function startEdit() {
    isEditing = true;
    editText = todo.text;
  }

  function saveEdit() {
    if (editText.trim()) {
      dispatch('edit', { id: todo.id, newText: editText.trim() });
    }
    isEditing = false;
  }

  function cancelEdit() {
    isEditing = false;
    editText = todo.text;
  }

  function handleKeydown(event: KeyboardEvent) {
    if (event.key === 'Enter') {
      saveEdit();
    } else if (event.key === 'Escape') {
      cancelEdit();
    }
  }
</script>

<li class="todo-item" class:completed={todo.completed}>
  <input
    type="checkbox"
    checked={todo.completed}
    on:change={handleToggle}
  />

  {#if isEditing}
    <input
      type="text"
      bind:value={editText}
      on:keydown={handleKeydown}
      on:blur={saveEdit}
      class="edit-input"
    />
  {:else}
    <span
      class="todo-text"
      on:dblclick={startEdit}
    >
      {todo.text}
    </span>
  {/if}

  <button on:click={handleDelete} class="delete-btn">
    削除
  </button>
</li>

親コンポーネントでイベントを受け取り、状態を管理します:

typescript<!-- TodoList.svelte -->
<script lang="ts">
  import TodoItem from './TodoItem.svelte';

  type Todo = {
    id: string;
    text: string;
    completed: boolean;
  };

  let todos: Todo[] = [
    { id: '1', text: 'Svelteを学習する', completed: false },
    { id: '2', text: 'カスタムイベントを実装する', completed: true }
  ];

  let newTodoText = '';

  function addTodo() {
    if (newTodoText.trim()) {
      const newTodo: Todo = {
        id: Date.now().toString(),
        text: newTodoText.trim(),
        completed: false
      };
      todos = [...todos, newTodo];
      newTodoText = '';
    }
  }
</script>

イベントハンドラーの実装:

typescript<!-- TodoList.svelte の続き -->
<script lang="ts">
  // ... 上記の続き

  function handleToggleTodo(event: CustomEvent<{ id: string }>) {
    const { id } = event.detail;
    todos = todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    );
  }

  function handleDeleteTodo(event: CustomEvent<{ id: string }>) {
    const { id } = event.detail;
    todos = todos.filter(todo => todo.id !== id);
  }

  function handleEditTodo(event: CustomEvent<{ id: string; newText: string }>) {
    const { id, newText } = event.detail;
    todos = todos.map(todo =>
      todo.id === id ? { ...todo, text: newText } : todo
    );
  }
</script>

<div class="todo-list">
  <form on:submit|preventDefault={addTodo}>
    <input
      type="text"
      bind:value={newTodoText}
      placeholder="新しいTODOを入力..."
    />
    <button type="submit">追加</button>
  </form>

  <ul>
    {#each todos as todo (todo.id)}
      <TodoItem
        {todo}
        on:toggle={handleToggleTodo}
        on:delete={handleDeleteTodo}
        on:edit={handleEditTodo}
      />
    {/each}
  </ul>
</div>

フォーム要素のカスタムバインディング実装

より高度な例として、バリデーション機能付きのカスタムフォームコンポーネントを実装してみましょう。

まず、バリデーション付きの入力フィールドコンポーネントを作成します:

typescript<!-- ValidatedInput.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let value = '';
  export let label = '';
  export let type: 'text' | 'email' | 'password' | 'number' = 'text';
  export let required = false;
  export let minLength = 0;
  export let maxLength = Infinity;
  export let pattern = '';

  const dispatch = createEventDispatcher<{
    input: { value: string; isValid: boolean };
    blur: { value: string; isValid: boolean };
  }>();

  let touched = false;
  let errorMessage = '';

  // バリデーション関数
  function validate(inputValue: string): { isValid: boolean; message: string } {
    if (required && !inputValue.trim()) {
      return { isValid: false, message: `${label}は必須項目です` };
    }

    if (inputValue.length < minLength) {
      return {
        isValid: false,
        message: `${label}${minLength}文字以上で入力してください`
      };
    }

    if (inputValue.length > maxLength) {
      return {
        isValid: false,
        message: `${label}${maxLength}文字以下で入力してください`
      };
    }

    if (pattern && !new RegExp(pattern).test(inputValue)) {
      return { isValid: false, message: `${label}の形式が正しくありません` };
    }

    return { isValid: true, message: '' };
  }
</script>

入力処理とイベント発火の実装:

typescript<!-- ValidatedInput.svelte の続き -->
<script lang="ts">
  // ... 上記の続き

  $: validation = validate(value);
  $: isValid = validation.isValid;
  $: if (touched) errorMessage = validation.message;

  function handleInput(event: Event) {
    const target = event.target as HTMLInputElement;
    value = target.value;
    dispatch('input', { value, isValid });
  }

  function handleBlur() {
    touched = true;
    dispatch('blur', { value, isValid });
  }
</script>

<div class="input-group">
  <label class="input-label">
    {label}
    {#if required}<span class="required">*</span>{/if}
  </label>

  <input
    {type}
    {value}
    class="input-field"
    class:error={touched && !isValid}
    class:valid={touched && isValid}
    on:input={handleInput}
    on:blur={handleBlur}
  />

  {#if touched && errorMessage}
    <span class="error-message">{errorMessage}</span>
  {/if}
</div>

<style>
  .input-group {
    margin-bottom: 1rem;
  }

  .input-field.error {
    border-color: #ef4444;
  }

  .input-field.valid {
    border-color: #10b981;
  }

  .error-message {
    color: #ef4444;
    font-size: 0.875rem;
  }

  .required {
    color: #ef4444;
  }
</style>

フォーム全体の状態を管理する親コンポーネント:

typescript<!-- UserRegistrationForm.svelte -->
<script lang="ts">
  import ValidatedInput from './ValidatedInput.svelte';

  let formData = {
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  };

  let fieldValidation = {
    username: false,
    email: false,
    password: false,
    confirmPassword: false
  };

  // フォーム全体の有効性をチェック
  $: isFormValid = Object.values(fieldValidation).every(valid => valid) &&
                   formData.password === formData.confirmPassword;

  $: passwordsMatch = formData.password === formData.confirmPassword;
</script>

各フィールドのバインディングとバリデーション:

typescript<!-- UserRegistrationForm.svelte の続き -->
<script lang="ts">
  // ... 上記の続き

  function handleFieldValidation(field: keyof typeof fieldValidation) {
    return (event: CustomEvent<{ value: string; isValid: boolean }>) => {
      const { value, isValid } = event.detail;
      formData[field] = value;
      fieldValidation[field] = isValid;
    };
  }

  function handleSubmit() {
    if (isFormValid) {
      console.log('フォーム送信:', formData);
      // API呼び出しなどの処理
    }
  }
</script>

<form on:submit|preventDefault={handleSubmit} class="registration-form">
  <h2>ユーザー登録</h2>

  <ValidatedInput
    bind:value={formData.username}
    label="ユーザー名"
    required
    minLength={3}
    maxLength={20}
    on:input={handleFieldValidation('username')}
  />

  <ValidatedInput
    bind:value={formData.email}
    label="メールアドレス"
    type="email"
    required
    pattern="^[^\s@]+@[^\s@]+\.[^\s@]+$"
    on:input={handleFieldValidation('email')}
  />

  <ValidatedInput
    bind:value={formData.password}
    label="パスワード"
    type="password"
    required
    minLength={8}
    on:input={handleFieldValidation('password')}
  />

  <ValidatedInput
    bind:value={formData.confirmPassword}
    label="パスワード確認"
    type="password"
    required
    on:input={handleFieldValidation('confirmPassword')}
  />

  {#if formData.confirmPassword && !passwordsMatch}
    <p class="error-message">パスワードが一致しません</p>
  {/if}

  <button type="submit" disabled={!isFormValid} class="submit-btn">
    登録
  </button>
</form>

複数コンポーネントにまたがるイベントチェーン

最後に、複数のコンポーネントを経由するイベントチェーンの例を実装してみましょう。ショッピングカートシステムの一部として、商品追加から合計金額更新までの流れを実装します。

mermaidsequenceDiagram
  participant ProductCard
  participant ProductList
  participant ShoppingCart
  participant CartSummary

  ProductCard->>ProductList: addToCart イベント
  ProductList->>ShoppingCart: itemAdded イベント
  ShoppingCart->>CartSummary: cartUpdated イベント
  CartSummary->>CartSummary: 合計金額を再計算

図解: 商品カードから始まるイベントチェーンが、最終的にカート合計の更新まで伝播する流れです。

商品カードコンポーネント:

typescript<!-- ProductCard.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let product: {
    id: string;
    name: string;
    price: number;
    image: string;
  };

  const dispatch = createEventDispatcher<{
    addToCart: { productId: string; quantity: number };
  }>();

  let quantity = 1;

  function handleAddToCart() {
    dispatch('addToCart', {
      productId: product.id,
      quantity
    });
    quantity = 1; // リセット
  }
</script>

<div class="product-card">
  <img src={product.image} alt={product.name} />
  <h3>{product.name}</h3>
  <p class="price">{product.price.toLocaleString()}円</p>

  <div class="add-to-cart">
    <input type="number" bind:value={quantity} min="1" max="10" />
    <button on:click={handleAddToCart}>
      カートに追加
    </button>
  </div>
</div>

商品リストコンポーネント(イベントフォワーディング):

typescript<!-- ProductList.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import ProductCard from './ProductCard.svelte';

  export let products: Array<{
    id: string;
    name: string;
    price: number;
    image: string;
  }>;

  const dispatch = createEventDispatcher<{
    itemAdded: {
      productId: string;
      quantity: number;
      productName: string;
      price: number;
    };
  }>();

  function handleAddToCart(event: CustomEvent<{ productId: string; quantity: number }>) {
    const { productId, quantity } = event.detail;
    const product = products.find(p => p.id === productId);

    if (product) {
      // 商品情報を追加してイベントを転送
      dispatch('itemAdded', {
        productId,
        quantity,
        productName: product.name,
        price: product.price
      });
    }
  }
</script>

<div class="product-grid">
  {#each products as product (product.id)}
    <ProductCard
      {product}
      on:addToCart={handleAddToCart}
    />
  {/each}
</div>

メインのショッピングカートコンポーネント:

typescript<!-- ShoppingCart.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import ProductList from './ProductList.svelte';
  import CartSummary from './CartSummary.svelte';

  const dispatch = createEventDispatcher<{
    cartUpdated: { items: CartItem[]; total: number };
  }>();

  type CartItem = {
    productId: string;
    productName: string;
    price: number;
    quantity: number;
  };

  let cartItems: CartItem[] = [];

  // 商品データ(通常はAPIから取得)
  const products = [
    { id: '1', name: 'ノートPC', price: 89800, image: '/laptop.jpg' },
    { id: '2', name: 'マウス', price: 2980, image: '/mouse.jpg' },
    { id: '3', name: 'キーボード', price: 5980, image: '/keyboard.jpg' }
  ];

  $: total = cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);

  // カート更新時に親にイベント通知
  $: if (cartItems) {
    dispatch('cartUpdated', { items: cartItems, total });
  }
</script>

カートアイテム追加の処理:

typescript<!-- ShoppingCart.svelte の続き -->
<script lang="ts">
  // ... 上記の続き

  function handleItemAdded(event: CustomEvent<{
    productId: string;
    quantity: number;
    productName: string;
    price: number;
  }>) {
    const { productId, quantity, productName, price } = event.detail;

    // 既存アイテムかチェック
    const existingItemIndex = cartItems.findIndex(item => item.productId === productId);

    if (existingItemIndex >= 0) {
      // 既存アイテムの数量を増やす
      cartItems[existingItemIndex].quantity += quantity;
      cartItems = cartItems; // リアクティビティをトリガー
    } else {
      // 新しいアイテムを追加
      cartItems = [...cartItems, {
        productId,
        productName,
        price,
        quantity
      }];
    }
  }

  function removeFromCart(productId: string) {
    cartItems = cartItems.filter(item => item.productId !== productId);
  }
</script>

<div class="shopping-cart">
  <div class="products-section">
    <h2>商品一覧</h2>
    <ProductList
      {products}
      on:itemAdded={handleItemAdded}
    />
  </div>

  <div class="cart-section">
    <h2>ショッピングカート</h2>
    <CartSummary
      {cartItems}
      {total}
      on:removeItem={(e) => removeFromCart(e.detail.productId)}
    />
  </div>
</div>

このような複数コンポーネントにまたがるイベントチェーンにより、アプリケーション全体の状態を適切に管理できます。

まとめ

Svelte のカスタムイベントと双方向バインディングシステムは、モダンな Web アプリケーション開発において非常に強力なツールです。本記事で学んだ内容を振り返り、実際の開発で活用できるベストプラクティスをまとめてみましょう。

カスタムイベント・バインディングのベストプラクティス

イベント設計における重要な原則

カスタムイベントを設計する際は、以下の原則に従うことが重要です。

まず、イベントの責任を明確に分離することです。一つのイベントが複数の責任を持たないよう、目的別にイベントを分けて設計しましょう。例えば、フォームの状態変更であれば、inputvalidatesubmitのように段階ごとにイベントを定義します。

次に、TypeScript の型安全性を最大限活用することです。createEventDispatcherに型パラメーターを指定し、イベントペイロードの型を明確に定義することで、開発時のエラー検出とリファクタリングの安全性が大幅に向上します。

typescript// 良い例:型安全なイベント定義
const dispatch = createEventDispatcher<{
  userAction: {
    userId: string;
    action: 'create' | 'update' | 'delete';
  };
  validationError: { field: string; message: string };
}>();

双方向バインディングの効果的な活用

双方向バインディングは、フォーム処理において特に威力を発揮します。ただし、パフォーマンスとメンテナンス性を考慮した使い方が重要です。

リアクティブステートメントと組み合わせることで、入力値の変更に応じた自動的な計算や検証が実現できます:

typescript$: isValidEmail =
  email.includes('@') && email.includes('.');
$: totalPrice = quantity * unitPrice;
$: formErrors = validateForm(formData);

また、カスタムコンポーネントでのバインディング実装では、入力値の正規化やバリデーションを適切に行い、親コンポーネントには常に有効な値が渡されるよう配慮しましょう。

コンポーネント間通信のパターン選択

複雑なアプリケーションでは、適切な通信パターンの選択が重要です。

  • 親子間の単純な状態共有: bind:による双方向バインディング
  • 子から親への通知: createEventDispatcherによるカスタムイベント
  • 複数階層にわたる通信: イベントフォワーディングまたは Svelte ストア
  • グローバル状態管理: Svelte ストアとリアクティブ宣言の組み合わせ

パフォーマンス最適化のコツ

Svelte のイベントシステムを使用する際の、パフォーマンス最適化のポイントをご紹介します。

イベントモディファイアを適切に使用することで、不要な処理を削減できます。特に|once|passive|preventDefaultは、用途に応じて積極的に活用しましょう。

また、大量のデータを扱う場合は、リアクティブステートメントの再計算を最小限に抑えるため、適切な依存関係の管理が重要です。

開発効率向上のテクニック

実際の開発現場では、以下のテクニックを活用することで開発効率を大幅に向上できます。

コンポーネントの再利用性を高めるため、汎用的なイベントインターフェースを定義し、具体的な実装は親コンポーネントに委ねる設計パターンが有効です。

デバッグ支援として、開発環境ではイベントの発火状況をコンソールに出力する仕組みを組み込むと、複雑なイベントチェーンの動作確認が容易になります。

typescript// 開発環境でのイベントログ出力
if (import.meta.env.DEV) {
  console.log('Event dispatched:', {
    type: 'userAction',
    detail: eventData,
  });
}

Svelte のカスタムイベントと双方向バインディングをマスターすることで、よりエレガントで保守性の高い Web アプリケーションを構築できるようになります。これらの機能を適切に活用し、ユーザーにとって快適で、開発者にとってメンテナンス性の高いアプリケーションを作り上げていきましょう。

実際のプロジェクトでは、小さな機能から始めて段階的に Svelte のイベントシステムに慣れ親しんでいくことをお勧めします。本記事で紹介したパターンを参考に、あなた自身のプロジェクトに最適な実装方法を見つけていってください。

関連リンク

Svelte のカスタムイベントと双方向バインディングについてさらに深く学習したい方のために、公式ドキュメントやコミュニティリソースをご紹介します。

公式ドキュメント

TypeScript 関連リソース

コミュニティとツール

実践的なリソース