T-CREATOR

Svelte のライフサイクルフック完全解説

Svelte のライフサイクルフック完全解説

Svelteのライフサイクルフックは、コンポーネントの生成から破棄までの各段階で実行される特別な関数です。これらを理解することで、より効率的で安定したWebアプリケーションを構築できるようになります。

本記事では、Svelteの5つの主要なライフサイクルフック(onMountbeforeUpdateafterUpdateonDestroytick)について、実際のコード例とよくあるエラーを交えながら詳しく解説していきます。

背景

Svelteコンポーネントの動作理解の重要性

Svelteアプリケーションを開発していると、「なぜこのタイミングでAPIを呼び出すのか」「いつDOMが更新されるのか」といった疑問が生まれますよね。これらの疑問を解決するカギが、ライフサイクルフックの理解にあります。

Svelteコンポーネントは、まるで生き物のように誕生し、成長し、そして役目を終えて消えていきます。この一連の流れを適切に管理することで、ユーザーにとって快適なWebアプリケーションを提供できるのです。

ライフサイクルフックとは何か

ライフサイクルフックは、コンポーネントの特定の瞬間に実行される関数です。ReactのuseEffectやVue.jsのmountedに似た概念ですが、Svelteではより直感的でシンプルな書き方ができるのが特徴です。

これらのフックを使うことで、以下のようなことが可能になります:

  • コンポーネントがブラウザに表示されたタイミングでデータを取得
  • 状態が変更される前後での処理実行
  • コンポーネントが削除される前のクリーンアップ処理

課題

ライフサイクルフックの実行タイミングがわからない

多くの開発者が最初につまずくのが、「どのタイミングで何が実行されるのか」という問題です。

typescript// よくある間違い:onMountを使わずにDOM操作を試みる
let element;

// これは動作しません!elementはまだundefined
console.log(element); // undefined
element.focus(); // TypeError: Cannot read property 'focus' of undefined

この問題は、コンポーネントがマウントされる前にDOM要素にアクセスしようとしたために発生します。

各フックの使い分けができない

「データ取得はonMountで」「DOM操作はafterUpdateで」といった基本的な使い分けがわからないことも多い課題です。間違った場面で使用すると、以下のようなエラーが発生します:

javascriptError: Function called outside component initialization
    at onMount (svelte/internal:...)

パフォーマンスに影響する使い方をしている

ライフサイクルフックの不適切な使用は、アプリケーションのパフォーマンスを大幅に低下させる可能性があります。

typescript// 危険な例:afterUpdateで重い処理を実行
afterUpdate(() => {
  // この処理は状態が変更されるたびに実行される
  expensiveCalculation(); // 重い計算処理
  callApiEveryUpdate(); // 不要なAPI呼び出し
});

解決策

各ライフサイクルフックの詳細解説

Svelteのライフサイクルフックは、コンポーネントの生涯において明確な役割を持っています。適切に使い分けることで、効率的で保守性の高いアプリケーションを構築できます。

実行順序と適切な使用場面

ライフサイクルフックは以下の順序で実行されます:

#フック名実行タイミング主な用途
1onMountコンポーネントがDOMにマウントされた直後データ取得、DOM要素の初期化
2beforeUpdate状態変更によるDOM更新の直前DOM更新前の値の保存
3afterUpdateDOM更新の直後DOM操作、更新後の処理
4onDestroyコンポーネントがDOMから削除される直前イベントリスナーの削除、タイマーのクリア
5tick次のマイクロタスクのタイミング状態更新後のDOM反映待ち

具体例

onMount

onMountは、コンポーネントがブラウザのDOMに初めて描画された直後に実行される最も重要なライフサイクルフックです。

基本的なデータ取得パターン

APIからデータを取得する際の定番パターンをご紹介します:

typescript<script>
  import { onMount } from 'svelte';
  
  let users = [];
  let loading = true;
  let error = null;

  onMount(async () => {
    try {
      const response = await fetch('/api/users');
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      users = await response.json();
    } catch (err) {
      error = err.message;
      console.error('Failed to fetch users:', err);
    } finally {
      loading = false;
    }
  });
</script>

{#if loading}
  <p>読み込み中...</p>
{:else if error}
  <p class="error">エラーが発生しました: {error}</p>
{:else}
  <ul>
    {#each users as user}
      <li>{user.name}</li>
    {/each}
  </ul>
{/if}

このコードでは、onMount内でエラーハンドリングを含む非同期処理を実装しています。finallyブロックを使うことで、成功・失敗に関わらずローディング状態を適切に終了させています。

DOM要素への直接アクセス

onMountでは、DOM要素が確実に存在するため、安全に操作を行えます:

typescript<script>
  import { onMount } from 'svelte';
  
  let inputElement;
  let canvasElement;

  onMount(() => {
    // 入力フィールドにフォーカスを設定
    inputElement.focus();
    
    // Canvas要素のコンテキストを取得
    const ctx = canvasElement.getContext('2d');
    ctx.fillStyle = '#ff0000';
    ctx.fillRect(10, 10, 100, 100);
    
    // 戻り値として関数を返すことで、onDestroyと同じ効果
    return () => {
      console.log('コンポーネントがアンマウントされます');
    };
  });
</script>

<input bind:this={inputElement} placeholder="自動的にフォーカスされます" />
<canvas bind:this={canvasElement} width="200" height="200"></canvas>

onMountから関数を返すことで、コンポーネントが破棄される際のクリーンアップ処理を定義できる点は、多くの開発者が見落としがちな便利な機能です。

beforeUpdate

beforeUpdateは、リアクティブな状態の変更によってDOMが更新される直前に実行されます。

スクロール位置の保存

リストの更新時にスクロール位置を維持したい場合の実装例です:

typescript<script>
  import { beforeUpdate, afterUpdate } from 'svelte';
  
  let items = ['アイテム1', 'アイテム2', 'アイテム3'];
  let listElement;
  let shouldMaintainScroll = false;
  let scrollTop = 0;

  beforeUpdate(() => {
    // 現在のスクロール位置を保存
    if (listElement) {
      scrollTop = listElement.scrollTop;
      shouldMaintainScroll = true;
    }
  });

  afterUpdate(() => {
    // スクロール位置を復元
    if (shouldMaintainScroll && listElement) {
      listElement.scrollTop = scrollTop;
      shouldMaintainScroll = false;
    }
  });

  function addItem() {
    items = [...items, `アイテム${items.length + 1}`];
  }
</script>

<button on:click={addItem}>アイテムを追加</button>

<div bind:this={listElement} class="list-container">
  {#each items as item}
    <div class="item">{item}</div>
  {/each}
</div>

<style>
  .list-container {
    height: 200px;
    overflow-y: scroll;
    border: 1px solid #ccc;
  }
  
  .item {
    padding: 10px;
    border-bottom: 1px solid #eee;
    height: 50px;
  }
</style>

この例では、beforeUpdateでスクロール位置を記録し、afterUpdateで復元することで、リストが更新されてもユーザーの閲覧位置を維持しています。

afterUpdate

afterUpdateは、DOM更新が完了した直後に実行され、更新されたDOM要素に対する操作を安全に行えます。

動的なサイズ調整

コンテンツの変更に合わせてレイアウトを調整する例をご紹介します:

typescript<script>
  import { afterUpdate } from 'svelte';
  
  let content = '短いテキスト';
  let containerElement;
  let contentHeight = 0;

  afterUpdate(() => {
    if (containerElement) {
      // DOM更新後の実際の高さを取得
      contentHeight = containerElement.scrollHeight;
      
      // 高さに応じてスタイルを動的に調整
      if (contentHeight > 200) {
        containerElement.style.overflow = 'scroll';
        containerElement.style.maxHeight = '200px';
      } else {
        containerElement.style.overflow = 'visible';
        containerElement.style.maxHeight = 'none';
      }
    }
  });

  function toggleContent() {
    content = content === '短いテキスト' 
      ? '長いテキストです。'.repeat(50)
      : '短いテキスト';
  }
</script>

<button on:click={toggleContent}>テキストを切り替え</button>

<div bind:this={containerElement} class="content-container">
  <p>{content}</p>
  <p>現在の高さ: {contentHeight}px</p>
</div>

<style>
  .content-container {
    border: 1px solid #ccc;
    padding: 10px;
    margin-top: 10px;
    transition: all 0.3s ease;
  }
</style>

この実装では、コンテンツの変更後にafterUpdateで実際のDOM要素のサイズを測定し、それに基づいてスタイルを動的に調整しています。

よくあるエラーとその対処法

afterUpdate使用時によく発生するエラーです:

typescript// 問題のあるコード:無限ループの発生
afterUpdate(() => {
  // この中で状態を変更すると無限ループが発生
  if (someCondition) {
    counter++; // これは危険!
  }
});

このエラーを回避するには、条件を適切に設定する必要があります:

typescript// 修正されたコード:条件を適切に設定
let previousValue = null;

afterUpdate(() => {
  if (someValue !== previousValue) {
    // 値が実際に変更された場合のみ処理を実行
    performAction();
    previousValue = someValue;
  }
});

onDestroy

onDestroyは、コンポーネントがDOMから削除される直前に実行され、メモリリークを防ぐために重要な役割を果たします。

イベントリスナーのクリーンアップ

適切なクリーンアップを行わないと、以下のようなエラーが発生する可能性があります:

vbnetWarning: Can't perform a React state update on an unmounted component
Memory leak detected: EventListener not removed

これを防ぐための実装例です:

typescript<script>
  import { onMount, onDestroy } from 'svelte';
  
  let windowWidth = 0;
  let scrollY = 0;

  function handleResize() {
    windowWidth = window.innerWidth;
  }

  function handleScroll() {
    scrollY = window.scrollY;
  }

  onMount(() => {
    // イベントリスナーを追加
    window.addEventListener('resize', handleResize);
    window.addEventListener('scroll', handleScroll);
    
    // 初期値を設定
    windowWidth = window.innerWidth;
    scrollY = window.scrollY;
  });

  onDestroy(() => {
    // イベントリスナーを確実に削除
    window.removeEventListener('resize', handleResize);
    window.removeEventListener('scroll', handleScroll);
    
    console.log('イベントリスナーをクリーンアップしました');
  });
</script>

<div class="info">
  <p>ウィンドウ幅: {windowWidth}px</p>
  <p>スクロール位置: {scrollY}px</p>
</div>

タイマーとインターバルのクリーンアップ

定期的な処理を行う場合のクリーンアップ実装です:

typescript<script>
  import { onMount, onDestroy } from 'svelte';
  
  let currentTime = new Date();
  let intervalId;

  onMount(() => {
    // 1秒ごとに時刻を更新
    intervalId = setInterval(() => {
      currentTime = new Date();
    }, 1000);
  });

  onDestroy(() => {
    // インターバルをクリア
    if (intervalId) {
      clearInterval(intervalId);
      console.log('タイマーをクリアしました');
    }
  });
</script>

<div class="clock">
  <h2>現在時刻</h2>
  <p>{currentTime.toLocaleTimeString()}</p>
</div>

クリーンアップを忘れると、コンポーネントが破棄された後もタイマーが動き続け、メモリリークの原因となります。

tick関数

tickは他のライフサイクルフックとは異なり、状態更新後のDOM反映を待つための特別な関数です。

状態更新後のDOM操作

状態を更新した直後にDOM要素にアクセスする場合の実装例です:

typescript<script>
  import { tick } from 'svelte';
  
  let items = ['項目1', '項目2', '項目3'];
  let newItemInput = '';
  let listElement;

  async function addItem() {
    if (newItemInput.trim()) {
      // 新しい項目を追加
      items = [...items, newItemInput.trim()];
      newItemInput = '';
      
      // DOM更新を待つ
      await tick();
      
      // 新しく追加された要素までスクロール
      const lastItem = listElement.lastElementChild;
      if (lastItem) {
        lastItem.scrollIntoView({ 
          behavior: 'smooth', 
          block: 'end' 
        });
      }
    }
  }
</script>

<div class="add-item">
  <input 
    bind:value={newItemInput} 
    placeholder="新しい項目を入力"
    on:keydown={(e) => e.key === 'Enter' && addItem()}
  />
  <button on:click={addItem}>追加</button>
</div>

<ul bind:this={listElement} class="item-list">
  {#each items as item, index}
    <li class="item">{index + 1}. {item}</li>
  {/each}
</ul>

<style>
  .item-list {
    max-height: 200px;
    overflow-y: auto;
    border: 1px solid #ccc;
    padding: 10px;
  }
  
  .item {
    padding: 8px;
    margin: 4px 0;
    background: #f5f5f5;
    border-radius: 4px;
  }
</style>

tick()を使用することで、状態更新後にDOMが確実に更新されてから、スクロール処理を実行できます。

フォーカス制御での活用

動的に生成される要素にフォーカスを設定する例です:

typescript<script>
  import { tick } from 'svelte';
  
  let isEditing = false;
  let editInputElement;
  let itemText = 'クリックして編集';

  async function startEditing() {
    isEditing = true;
    
    // DOM更新を待ってからフォーカス
    await tick();
    
    if (editInputElement) {
      editInputElement.focus();
      editInputElement.select(); // テキストを全選択
    }
  }

  function finishEditing() {
    isEditing = false;
  }
</script>

<div class="editable-item">
  {#if isEditing}
    <input 
      bind:this={editInputElement}
      bind:value={itemText}
      on:blur={finishEditing}
      on:keydown={(e) => e.key === 'Enter' && finishEditing()}
      class="edit-input"
    />
  {:else}
    <span on:click={startEditing} class="display-text">
      {itemText}
    </span>
  {/if}
</div>

<style>
  .editable-item {
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    cursor: pointer;
  }
  
  .display-text {
    display: block;
    width: 100%;
  }
  
  .edit-input {
    width: 100%;
    border: none;
    outline: none;
    font-size: inherit;
  }
</style>

ライフサイクルフックの組み合わせパターン

実際のアプリケーションでは、複数のライフサイクルフックを組み合わせて使用することが一般的です。

リアルタイムデータ表示コンポーネント

WebSocketを使用したリアルタイムデータ表示の完全な実装例です:

typescript<script>
  import { onMount, onDestroy, beforeUpdate, afterUpdate, tick } from 'svelte';
  
  let messages = [];
  let websocket;
  let isConnected = false;
  let messagesContainer;
  let shouldScrollToBottom = true;

  onMount(async () => {
    try {
      // WebSocket接続を確立
      websocket = new WebSocket('wss://api.example.com/messages');
      
      websocket.onopen = () => {
        isConnected = true;
        console.log('WebSocket接続が確立されました');
      };
      
      websocket.onmessage = async (event) => {
        const newMessage = JSON.parse(event.data);
        messages = [...messages, newMessage];
        
        // 新しいメッセージが追加された場合、自動スクロール
        await tick();
        if (shouldScrollToBottom) {
          scrollToBottom();
        }
      };
      
      websocket.onerror = (error) => {
        console.error('WebSocketエラー:', error);
      };
      
      websocket.onclose = () => {
        isConnected = false;
        console.log('WebSocket接続が閉じられました');
      };
      
    } catch (error) {
      console.error('WebSocket接続に失敗しました:', error);
    }
  });

  beforeUpdate(() => {
    // スクロール位置をチェック
    if (messagesContainer) {
      const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
      shouldScrollToBottom = scrollTop + clientHeight >= scrollHeight - 5;
    }
  });

  afterUpdate(() => {
    // 必要に応じて自動スクロール
    if (shouldScrollToBottom) {
      scrollToBottom();
    }
  });

  onDestroy(() => {
    // WebSocket接続をクリーンアップ
    if (websocket) {
      websocket.close();
      console.log('WebSocketを正常に閉じました');
    }
  });

  function scrollToBottom() {
    if (messagesContainer) {
      messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }
  }

  function sendMessage() {
    if (websocket && isConnected) {
      const message = {
        text: 'Hello from client',
        timestamp: new Date().toISOString()
      };
      websocket.send(JSON.stringify(message));
    }
  }
</script>

<div class="chat-container">
  <div class="connection-status">
    状態: {isConnected ? '接続中' : '切断中'}
  </div>
  
  <div bind:this={messagesContainer} class="messages-container">
    {#each messages as message}
      <div class="message">
        <span class="timestamp">{new Date(message.timestamp).toLocaleTimeString()}</span>
        <span class="text">{message.text}</span>
      </div>
    {/each}
  </div>
  
  <button on:click={sendMessage} disabled={!isConnected}>
    メッセージを送信
  </button>
</div>

<style>
  .chat-container {
    width: 100%;
    max-width: 500px;
    border: 1px solid #ccc;
    border-radius: 8px;
    overflow: hidden;
  }
  
  .connection-status {
    padding: 10px;
    background: #f0f0f0;
    font-weight: bold;
  }
  
  .messages-container {
    height: 300px;
    overflow-y: auto;
    padding: 10px;
  }
  
  .message {
    margin-bottom: 10px;
    padding: 8px;
    background: #f9f9f9;
    border-radius: 4px;
  }
  
  .timestamp {
    font-size: 0.8em;
    color: #666;
    margin-right: 10px;
  }
</style>

この実装では、5つのライフサイクルフックすべてを効果的に活用しています:

  • onMount: WebSocket接続の確立
  • beforeUpdate: スクロール位置の状態確認
  • afterUpdate: 自動スクロールの実行
  • onDestroy: WebSocket接続のクリーンアップ
  • tick: メッセージ追加後のDOM更新待ち

まとめ

Svelteのライフサイクルフックは、コンポーネントの各段階で適切な処理を実行するための強力なツールです。本記事で学んだ内容を整理しましょう。

各フックの主な役割

  • onMount: 初期化処理とデータ取得
  • beforeUpdate: DOM更新前の状態保存
  • afterUpdate: DOM更新後の操作
  • onDestroy: リソースのクリーンアップ
  • tick: 状態更新後のDOM反映待ち

実践で意識すべきポイント

  • エラーハンドリングを適切に実装する
  • メモリリークを防ぐためのクリーンアップを忘れない
  • 無限ループを避けるための条件設定を行う
  • パフォーマンスを考慮した実装を心がける

これらのライフサイクルフックを適切に使い分けることで、ユーザーにとって快適で、開発者にとって保守しやすいWebアプリケーションを構築できるようになります。

ライフサイクルフックは最初は複雑に感じるかもしれませんが、一度理解すれば、Svelteの真の力を発揮できる重要な機能です。ぜひ実際のプロジェクトで活用してみてください。

関連リンク