Vue3でコンポーネントを書いていると「onMountedは使うけれど、ほかのフックはいつ使えばいいのかわからない」という疑問に突き当たることがあります。Vue3のライフサイクルとは、コンポーネントが生成されてからDOMに挿入され、データ変更に応じて更新され、最終的に破棄されるまでの一連の過程を指します。各フェーズで呼び出される関数がライフサイクルフックであり、適切なフックを選べるかどうかが、バグの少ない効率的な開発に直結します。

Vue3コンポーネントのライフサイクルとは

Vueコンポーネントのインスタンスは、生成から破棄までに4つの主要フェーズを通過します。

  1. 生成(Creation) – リアクティブデータの初期化、propsの解決、setup()の実行
  2. マウント(Mount) – テンプレートのコンパイルとDOMツリーへの挿入
  3. 更新(Update) – リアクティブデータの変更によるDOMの再描画
  4. アンマウント(Unmount) – DOMからの取り外しとリソースの解放

ライフサイクルフックは、これらの各フェーズの「直前」または「直後」に独自の処理を差し込むための仕組みです。たとえばAPI通信はマウント後に行い、イベントリスナーの解除はアンマウント時に行う、という形でフェーズに応じた処理を分離できます。

Vue2のOptions APIではcreatedmountedといったオプション名でフックを定義していましたが、Vue3のComposition APIではonMountedのようにonプレフィックス付きの関数をsetup()内で呼び出す形式に変わりました。Options APIも引き続き利用できますが、Composition APIの方がロジックの分離・再利用に優れているため、新規開発ではComposition APIが推奨されています(参照: Vue.js公式ドキュメント)。

Vue3ライフサイクルの全フック一覧と発火順序

Vue3が提供するライフサイクルフックを、発火する順番に並べて整理します。

フック対応表(発火順)

順番Options APIComposition APIVue2対応発火タイミング
1beforeCreatesetup()内で自動実行beforeCreateインスタンス初期化直後、props解決後
2createdsetup()内で自動実行createdリアクティブデータ・computed・watch設定完了後
3beforeMountonBeforeMountbeforeMountDOM描画の直前
4mountedonMountedmountedDOMツリーが作成されて親コンテナに挿入された後
5beforeUpdateonBeforeUpdatebeforeUpdateリアクティブデータ変更後、DOM更新の直前
6updatedonUpdatedupdatedDOM更新が完了した後
7beforeUnmountonBeforeUnmountbeforeDestroyコンポーネント取り外しの直前
8unmountedonUnmounteddestroyedコンポーネント取り外し完了後
-activatedonActivatedactivatedKeepAliveキャッシュからDOMに再挿入された後
-deactivatedonDeactivateddeactivatedKeepAliveキャッシュに格納された後
-errorCapturedonErrorCapturederrorCaptured子孫コンポーネントでエラーが発生した時
-renderTrackedonRenderTracked依存関係が追跡された時(開発モード限定)
-renderTriggeredonRenderTriggered依存関係が再レンダリングを起こした時(開発モード限定)
-serverPrefetchonServerPrefetchサーバーサイドレンダリングの描画前(SSR限定)

Vue2のbeforeDestroy/destroyedはVue3でbeforeUnmount/unmountedに名称変更されました。Composition APIではbeforeCreatecreatedに対応する専用フックは存在せず、setup()自体がその役割を果たします。

発火順序を実際に確認するコード

全フックがどの順番で呼ばれるかをコンソールで確認できるコンポーネントの例です。

<script setup lang="ts">
import {
  ref,
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onRenderTracked,
  onRenderTriggered,
} from 'vue'

console.log('1. setup() -- リアクティブデータの初期化開始')

const count = ref(0)

onBeforeMount(() => {
  console.log('2. onBeforeMount -- DOM描画の直前')
})

onRenderTracked((e) => {
  console.log('   onRenderTracked -- 依存関係を追跡:', e.key)
})

onMounted(() => {
  console.log('3. onMounted -- DOMが挿入された')
})

onBeforeUpdate(() => {
  console.log('4. onBeforeUpdate -- データ変更後、DOM更新の直前')
})

onRenderTriggered((e) => {
  console.log('   onRenderTriggered -- 再レンダリングのトリガー:', e.key)
})

onUpdated(() => {
  console.log('5. onUpdated -- DOM更新が完了した')
})

onBeforeUnmount(() => {
  console.log('6. onBeforeUnmount -- 取り外しの直前')
})

onUnmounted(() => {
  console.log('7. onUnmounted -- 取り外し完了')
})

const increment = () => {
  count.value++
}
</script>

<template>
  <div>
    <p>カウント: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

このコンポーネントをマウントするとコンソールに1 → 2 → 3の順で出力され、ボタンをクリックすると4 → 5が表示されます。コンポーネントが画面から取り除かれると6 → 7が出力されます。実際に手元で動かして確認すると、フックの発火タイミングが体感的に理解できます。

各フックの役割と具体的なコード例

setup()(旧beforeCreate / created相当)

Composition APIの<script setup>ブロック内のトップレベルコードは、Options APIのbeforeCreatecreatedを合わせた役割を担います。setup()はコンポーネントインスタンスが作られた直後、propsが解決された段階で同期的に実行されます。

<script setup lang="ts">
import { ref, computed } from 'vue'

// setup()のトップレベル = 旧beforeCreate + created相当
const userId = ref(1)
const displayName = computed(() => `User #${userId.value}`)

// ここではDOMにアクセスできない(まだ描画前)
console.log(document.querySelector('#app')) // null
</script>

注意点: setup()はDOMが存在しない段階で実行されるため、document.querySelectorなどのDOM操作は行えません。DOM操作が必要な場合はonMountedを使います。

async setupの落とし穴

<script setup>の中でawaitをトップレベルに書くと、コンパイラがコンポーネントをasync setup()として扱います。この場合、<Suspense>で囲まないとコンポーネントが描画されません。

<!-- この書き方はSuspenseが必要 -->
<script setup lang="ts">
const res = await fetch('/api/data')
const data = await res.json()
</script>

意図せずトップレベルawaitを使うと表示されないコンポーネントが生まれるため、API呼び出しはonMounted内で行うのが一般的なパターンです。

onBeforeMount / onMounted

onBeforeMountはDOMが描画される直前に呼ばれます。リアクティブデータはすべてセットアップ済みですが、DOMノードはまだ作られていません。

onMountedはDOMツリーが作成され、親コンテナに挿入された後に呼ばれます。すべての同期的な子コンポーネントのマウントも完了した状態です。

<script setup lang="ts">
import { ref, onBeforeMount, onMounted } from 'vue'

const containerRef = ref<HTMLDivElement | null>(null)
const items = ref<string[]>([])

onBeforeMount(() => {
  // DOMはまだ存在しない
  console.log(containerRef.value) // null
})

onMounted(async () => {
  // DOMにアクセスできる
  console.log(containerRef.value) // <div>...</div>

  // API呼び出しの典型パターン
  try {
    const res = await fetch('/api/items')
    items.value = await res.json()
  } catch (e) {
    console.error('データ取得に失敗しました:', e)
  }
})
</script>

<template>
  <div ref="containerRef">
    <ul>
      <li v-for="item in items" :key="item">{{ item }}</li>
    </ul>
  </div>
</template>

onMountedはVue3で最も頻繁に使われるフックです。主な用途は以下のとおりです。

  • API呼び出し: コンポーネント表示時のデータ取得
  • DOM操作: Canvas、サードパーティライブラリ(Chart.js、D3.js等)の初期化
  • イベントリスナーの登録: window.addEventListenerなどグローバルなリスナー

onMounted内でのasync処理の書き方

onMountedのコールバックに直接asyncキーワードを付けても問題ありません。Vue3はコールバックの戻り値を使用しないため、Promiseが返されても無視されます。ただし、コンポーネントが即座にアンマウントされた場合にフェッチ結果を適用しないよう、ガード処理を入れるとより堅牢になります。

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const data = ref<Record<string, unknown> | null>(null)
let isMounted = true

onMounted(async () => {
  const res = await fetch('/api/data')
  const json = await res.json()
  // アンマウント後にstateを更新しない
  if (isMounted) {
    data.value = json
  }
})

onUnmounted(() => {
  isMounted = false
})
</script>

onBeforeUpdate / onUpdated

onBeforeUpdateはリアクティブデータが変更された後、VueがDOMを更新する直前に呼ばれます。DOM更新前の状態にアクセスする必要がある場合(たとえばスクロール位置の保存)に使います。このフック内でリアクティブデータを変更しても安全です。

onUpdatedはDOMの更新が完了した後に呼ばれます。親コンポーネントのonUpdatedは子コンポーネントのonUpdatedの後に呼ばれます。

<script setup lang="ts">
import { ref, onBeforeUpdate, onUpdated, nextTick } from 'vue'

const messages = ref<string[]>(['初期メッセージ'])
const listRef = ref<HTMLUListElement | null>(null)
let prevScrollHeight = 0

onBeforeUpdate(() => {
  // DOM更新前のスクロール高さを保存
  if (listRef.value) {
    prevScrollHeight = listRef.value.scrollHeight
  }
})

onUpdated(() => {
  // DOM更新後にスクロール位置を調整
  if (listRef.value) {
    const newScrollHeight = listRef.value.scrollHeight
    if (newScrollHeight > prevScrollHeight) {
      listRef.value.scrollTop = listRef.value.scrollHeight
    }
  }
})

const addMessage = () => {
  messages.value.push(`メッセージ ${messages.value.length + 1}`)
}
</script>

<template>
  <ul ref="listRef" style="max-height: 200px; overflow-y: auto;">
    <li v-for="(msg, i) in messages" :key="i">{{ msg }}</li>
  </ul>
  <button @click="addMessage">メッセージ追加</button>
</template>

重要な注意点: onUpdated内でリアクティブデータを変更すると、再びDOMの更新が発生し、onUpdatedが再度呼ばれます。結果として無限ループに陥る可能性があります。特定のデータ変更後のDOM状態を取得したい場合は、onUpdatedではなくnextTick()を使います。

<script setup lang="ts">
import { ref, nextTick } from 'vue'

const count = ref(0)

const increment = async () => {
  count.value++
  // この時点ではDOMはまだ古い
  await nextTick()
  // nextTick後はDOMが最新の状態
  console.log(document.getElementById('count')?.textContent)
}
</script>

onBeforeUnmount / onUnmounted

onBeforeUnmountはコンポーネントがDOMから取り外される直前に呼ばれます。この時点ではコンポーネントインスタンスはまだ完全に機能しています。

onUnmountedは取り外しが完了した後に呼ばれます。すべての子コンポーネントのアンマウントとリアクティブエフェクトの停止が完了した状態です。

この2つのフックはリソースの解放に不可欠です。登録したリスナーやタイマーを解除しないとメモリリークの原因になります。

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'

const position = ref({ x: 0, y: 0 })
let timerId: ReturnType<typeof setInterval> | null = null
let ws: WebSocket | null = null
let abortController: AbortController | null = null

// パターン1: イベントリスナーの登録と解除
const handleMouseMove = (e: MouseEvent) => {
  position.value = { x: e.clientX, y: e.clientY }
}

onMounted(() => {
  window.addEventListener('mousemove', handleMouseMove)

  // パターン2: タイマーの開始
  timerId = setInterval(() => {
    console.log('定期処理実行')
  }, 5000)

  // パターン3: WebSocket接続
  ws = new WebSocket('wss://example.com/ws')
  ws.onmessage = (e) => {
    console.log('受信:', e.data)
  }

  // パターン4: AbortControllerを使ったfetchの管理
  abortController = new AbortController()
})

onBeforeUnmount(() => {
  // 進行中のfetchをキャンセル
  abortController?.abort()
})

onUnmounted(() => {
  // イベントリスナーの解除
  window.removeEventListener('mousemove', handleMouseMove)

  // タイマーの停止
  if (timerId !== null) {
    clearInterval(timerId)
  }

  // WebSocket切断
  ws?.close()
})
</script>

<template>
  <p>マウス位置: ({{ position.x }}, {{ position.y }})</p>
</template>

メモリリーク防止のために意識すべきリソースをまとめます。

リソース登録タイミング解除方法
addEventListeneronMountedonUnmountedでremoveEventListener
setInterval / setTimeoutonMountedonUnmountedでclearInterval / clearTimeout
WebSocketonMountedonUnmountedでclose()
MutationObserveronMountedonUnmountedでdisconnect()
IntersectionObserveronMountedonUnmountedでdisconnect()
fetch / XMLHttpRequestonMountedonBeforeUnmountでAbortController.abort()
サードパーティライブラリonMountedonUnmountedでdestroy()等のクリーンアップ

onActivated / onDeactivated(KeepAlive専用フック)

<KeepAlive>でラップされたコンポーネントは、表示・非表示が切り替わっても破棄されずメモリ上にキャッシュされます。通常のコンポーネントでは非表示時にonUnmountedが呼ばれますが、KeepAlive内のコンポーネントでは代わりにonDeactivatedが呼ばれ、再表示時にonActivatedが呼ばれます。

<!-- 親コンポーネント -->
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'

const currentTab = shallowRef(TabA)
</script>

<template>
  <button @click="currentTab = TabA">タブA</button>
  <button @click="currentTab = TabB">タブB</button>
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>
<!-- TabA.vue -->
<script setup lang="ts">
import {
  ref,
  onMounted,
  onUnmounted,
  onActivated,
  onDeactivated,
} from 'vue'

const lastActivatedAt = ref('')

onMounted(() => {
  console.log('TabA: onMounted(初回のみ)')
})

onActivated(() => {
  // タブが表示されるたびに呼ばれる(初回マウント時も含む)
  lastActivatedAt.value = new Date().toLocaleTimeString()
  console.log('TabA: onActivated')
})

onDeactivated(() => {
  // タブが非表示になるたびに呼ばれる
  console.log('TabA: onDeactivated')
})

onUnmounted(() => {
  // KeepAlive内では通常呼ばれない
  console.log('TabA: onUnmounted')
})
</script>

<template>
  <div>
    <p>タブA - 最終表示: {{ lastActivatedAt }}</p>
  </div>
</template>

onActivatedは初回マウント時にもonMountedの後に呼ばれます。再表示時はマウントは発生せずonActivatedのみが呼ばれます。タブ切り替えUIやページ遷移でコンポーネントの状態を保持したい場面で役立ちます。

KeepAlive + ライフサイクルの発火順序:

  • 初回表示: setup → onBeforeMount → onMounted → onActivated
  • 非表示: onDeactivated(onUnmountedは呼ばれない)
  • 再表示: onActivatedのみ

onErrorCaptured(エラーバウンダリの構築)

onErrorCapturedは子孫コンポーネントで発生したエラーを親コンポーネントで捕捉するためのフックです。Reactの Error Boundary に相当する機能をVue3で実現できます。

捕捉できるエラーの発生源は以下のとおりです。

  • コンポーネントのレンダリング
  • イベントハンドラー
  • ライフサイクルフック
  • setup()関数
  • ウォッチャー
  • カスタムディレクティブフック
  • トランジションフック
<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const error = ref<Error | null>(null)
const errorInfo = ref('')

onErrorCaptured((err, instance, info) => {
  error.value = err instanceof Error ? err : new Error(String(err))
  errorInfo.value = info

  // falseを返すとエラーの親への伝播を停止する
  return false
})

const reset = () => {
  error.value = null
  errorInfo.value = ''
}
</script>

<template>
  <div v-if="error" class="error-fallback">
    <h3>エラーが発生しました</h3>
    <p>{{ error.message }}</p>
    <p>発生元: {{ errorInfo }}</p>
    <button @click="reset">再試行</button>
  </div>
  <slot v-else />
</template>
<!-- 使用側 -->
<template>
  <ErrorBoundary>
    <SomeUnstableComponent />
  </ErrorBoundary>
</template>

onErrorCapturedのコールバックでfalseを返すと、そのエラーは親コンポーネントやグローバルのapp.config.errorHandlerに伝播しません。falseを返さなかった場合、エラーはコンポーネントツリーを上方に伝播し続けます。

onRenderTracked / onRenderTriggered(開発専用デバッグフック)

この2つのフックは開発モード(dev mode)でのみ動作し、本番ビルドでは呼ばれません。コンポーネントの再レンダリングがどのリアクティブ依存関係によって引き起こされたかを特定するためのデバッグ用フックです。

  • onRenderTracked: レンダリングエフェクトがリアクティブな依存関係を追跡した時に呼ばれます。初回レンダリング時に、どのリアクティブデータが追跡対象になったかを確認できます。
  • onRenderTriggered: リアクティブな依存関係の変更がレンダリングの再実行を引き起こした時に呼ばれます。「なぜ再レンダリングが起きたのか」を突き止めるのに使います。
<script setup lang="ts">
import { ref, onRenderTracked, onRenderTriggered } from 'vue'

const count = ref(0)
const name = ref('Vue')

onRenderTracked((event) => {
  // 初回レンダリング時にcount, nameそれぞれについて呼ばれる
  console.log('追跡対象:', {
    target: event.target,
    type: event.type,  // 'get' | 'has' | 'iterate'
    key: event.key,
  })
})

onRenderTriggered((event) => {
  // countやnameが変更されて再レンダリングが発生した時
  console.log('再レンダリングのトリガー:', {
    type: event.type,  // 'set' | 'add' | 'delete' | 'clear'
    key: event.key,
    newValue: event.newValue,
    oldValue: event.oldValue,
  })
})
</script>

<template>
  <p>{{ count }} - {{ name }}</p>
  <button @click="count++">カウント+1</button>
</template>

「特定のコンポーネントが頻繁に再レンダリングされるが原因がわからない」という場面で、onRenderTriggeredを仕込むと、どのrefやreactiveプロパティの変更が引き金になっているかをピンポイントで把握できます。

ユースケース別・フック選択ガイド

「何をしたいか」に応じてどのフックを選ぶべきかを表にまとめます。

やりたいこと推奨フック理由
APIからデータを取得するonMountedDOMは不要だがコンポーネント初期化完了後が安全。SSR時はonServerPrefetch
DOM要素の参照(ref)を取得するonMountedDOMが存在するのはonMounted以降
Canvas / Chart.js等を初期化するonMountedライブラリがDOM要素を必要とするため
グローバルイベントリスナーを登録するonMounted + onUnmounted登録と解除をセットで行う
タイマーを開始・停止するonMounted + onUnmountedclearIntervalを忘れるとメモリリーク
WebSocket接続の開始・切断onMounted + onUnmountedclose()を忘れると接続が残る
DOM更新前のスクロール位置を保存するonBeforeUpdateDOM更新前の状態にアクセスできる唯一のタイミング
DOM更新後のサイズを測定するonUpdatedまたはnextTick特定のデータ変更に紐づくならnextTickの方が安全
子コンポーネントのエラーを捕捉するonErrorCapturedError Boundaryパターン
KeepAliveキャッシュの再表示時にデータ更新onActivated再表示のたびに呼ばれる
再レンダリングの原因を調査するonRenderTriggered開発モード限定のデバッグ用
SSR時にサーバーでデータを事前取得するonServerPrefetchサーバー側でPromise完了を待ってから描画

watchとcomputedはライフサイクルとどう関わるか

watchcomputedはライフサイクルフックではありませんが、コンポーネントのライフサイクルと密接に関わります。

computedの再計算タイミング

computedはリアクティブな依存関係が変更された時に再計算されます。ただし計算は遅延評価(lazy)で、実際に値が読み取られるまで再計算は実行されません。

<script setup lang="ts">
import { ref, computed } from 'vue'

const firstName = ref('太郎')
const lastName = ref('山田')

// firstNameまたはlastNameが変わった時に再計算される
// ただし実際にfullNameが参照されるまで計算は保留される
const fullName = computed(() => `${lastName.value} ${firstName.value}`)
</script>

<template>
  <p>{{ fullName }}</p>
</template>

computedはゲッターに副作用を含めるべきではありません。データの変換・フィルタリングといった純粋な計算に使うのが正しい用途です。

watchの実行タイミング

watchはリアクティブデータの変更を検知して副作用を実行します。デフォルトではコンポーネントのDOMが更新されるにコールバックが実行されます。

<script setup lang="ts">
import { ref, watch } from 'vue'

const searchQuery = ref('')
const results = ref<string[]>([])

// searchQueryが変わるたびに実行される
watch(searchQuery, async (newQuery, oldQuery) => {
  if (newQuery.length < 2) {
    results.value = []
    return
  }
  const res = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
  results.value = await res.json()
})
</script>

watchにはflushオプションがあり、コールバックの実行タイミングを制御できます。

flush値実行タイミング用途
'pre'(デフォルト)DOM更新前データの整合性チェック
'post'DOM更新後DOM更新後の値を読み取りたい場合
'sync'即時(バッチングなし)テストやデバッグ用。通常は非推奨
<script setup lang="ts">
import { ref, watch } from 'vue'

const count = ref(0)

// DOM更新後に実行される
watch(count, (newVal) => {
  // この時点でDOMは更新済み
  console.log(document.getElementById('count')?.textContent)
}, { flush: 'post' })
</script>

watchEffectとの違い

watchEffectは依存関係を明示的に指定せず、コールバック内で参照されたリアクティブデータを自動追跡します。setup()の実行時に即座に1回実行される点がwatchとの大きな違いです。

<script setup lang="ts">
import { ref, watchEffect } from 'vue'

const userId = ref(1)
const userData = ref<Record<string, unknown> | null>(null)

// 即座に実行され、userId.valueが変わるたびに再実行される
watchEffect(async () => {
  const res = await fetch(`/api/users/${userId.value}`)
  userData.value = await res.json()
})
</script>

ライフサイクルフックとwatch/computedの使い分けの基準:

  • computed: 値の算出(副作用なし)。テンプレートやほかのcomputedで参照される派生データ
  • watch / watchEffect: リアクティブデータの変更に応じた副作用(API呼び出し、ログ記録、外部ライブラリの更新など)
  • ライフサイクルフック: コンポーネントのフェーズに依存する処理(DOM操作、リソースの確保/解放、初期化/クリーンアップ)

カスタムComposableでライフサイクルを再利用する

Vue3 Composition APIの大きな利点の一つが、ライフサイクルフックを含むロジックをComposable関数(useXxxパターン)として切り出し、複数のコンポーネントで再利用できることです。

useWindowSize – ウィンドウサイズの監視

// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowSize() {
  const width = ref(0)
  const height = ref(0)

  const update = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => {
    update()
    window.addEventListener('resize', update)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', update)
  })

  return { width, height }
}
<!-- 使用側 -->
<script setup lang="ts">
import { useWindowSize } from '@/composables/useWindowSize'

const { width, height } = useWindowSize()
</script>

<template>
  <p>ウィンドウサイズ: {{ width }} x {{ height }}</p>
</template>

useWindowSize内でonMountedonUnmountedを呼んでいますが、これはComposableがsetup()の同期的な実行コンテキスト内で呼ばれる限り正常に動作します。コンポーネントがマウントされればonMountedが発火し、アンマウントされればonUnmountedが発火します。

useInterval – 自動停止するインターバル

// composables/useInterval.ts
import { onMounted, onUnmounted } from 'vue'

export function useInterval(callback: () => void, ms: number) {
  let id: ReturnType<typeof setInterval> | null = null

  onMounted(() => {
    id = setInterval(callback, ms)
  })

  onUnmounted(() => {
    if (id !== null) {
      clearInterval(id)
    }
  })
}
<script setup lang="ts">
import { ref } from 'vue'
import { useInterval } from '@/composables/useInterval'

const elapsed = ref(0)
useInterval(() => {
  elapsed.value++
}, 1000)
</script>

<template>
  <p>経過時間: {{ elapsed }}</p>
</template>

useIntersectionObserver – 要素の可視性を監視

// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue'

export function useIntersectionObserver(
  targetRef: Ref<HTMLElement | null>,
  options?: IntersectionObserverInit
) {
  const isVisible = ref(false)
  let observer: IntersectionObserver | null = null

  onMounted(() => {
    if (!targetRef.value) return

    observer = new IntersectionObserver(([entry]) => {
      isVisible.value = entry.isIntersecting
    }, options)

    observer.observe(targetRef.value)
  })

  onUnmounted(() => {
    observer?.disconnect()
  })

  return { isVisible }
}

Composable内でライフサイクルフックを使う際の制約は1つだけです。Composable関数はsetup()の同期的な実行フロー内で呼び出す必要がありますsetTimeoutawaitの後で呼び出すと、現在のコンポーネントインスタンスとの紐付けが失われ、フックが正しく登録されません。

<script setup lang="ts">
import { useWindowSize } from '@/composables/useWindowSize'

// OK: setup()の同期フロー内
const { width, height } = useWindowSize()

// NG: awaitの後では正しく動作しない可能性がある
// const data = await fetch('/api')
// const { width, height } = useWindowSize() // 紐付けが切れる
</script>

Vue2からVue3へのライフサイクル移行対応表

Vue2からVue3へ移行する際、ライフサイクルフックの名称や扱いが変わった部分があります。

Options APIの変更点

Vue2Vue3 Options API変更内容
beforeCreatebeforeCreate変更なし
createdcreated変更なし
beforeMountbeforeMount変更なし
mountedmounted変更なし
beforeUpdatebeforeUpdate変更なし
updatedupdated変更なし
beforeDestroybeforeUnmount名称変更
destroyedunmounted名称変更

Options APIではbeforeDestroybeforeUnmountdestroyedunmountedの2つが名称変更されました。Vue3でもbeforeDestroy/destroyedはエイリアスとして一時的に動作しますが、非推奨のため新しい名称に置き換えるべきです。

Composition APIへの対応

Vue2 Options APIVue3 Composition API備考
beforeCreatesetup()の先頭専用フック不要
createdsetup()の先頭専用フック不要
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeDestroyonBeforeUnmount名称も変更
destroyedonUnmounted名称も変更
activatedonActivated
deactivatedonDeactivated
errorCapturedonErrorCaptured
onRenderTrackedVue3で新規追加
onRenderTriggeredVue3で新規追加
onServerPrefetchVue3で新規追加

移行時に特に注意すべきポイント

1. beforeCreate/createdの代替

Vue2でcreatedにAPI呼び出しを書いていた場合、Vue3のComposition APIではsetup()のトップレベルまたはonMountedに移動します。

// Vue2
export default {
  created() {
    this.fetchData()
  }
}

// Vue3 Composition API
// setup()のトップレベルに書くとcreated相当のタイミングで実行される
// ただしAPI呼び出しはonMountedに書くのが一般的

2. thisへのアクセス

Composition APIにはOptions APIのthisに相当するものがありません。this.$refsref()で、this.$emitdefineEmits()で、this.$routeruseRouter()で代替します。

3. VNodeライフサイクルイベントの変更

Vue2ではテンプレート内で@hook:mountedのようにVNodeのライフサイクルイベントを監視できましたが、Vue3では@vnode-mountedに変更されています(参照: Vue3移行ガイド)。

SSR環境でのライフサイクルの違い

Nuxt3やVue3のSSR(Server-Side Rendering)環境では、ライフサイクルフックの挙動がクライアントサイドとは異なります。

サーバー側で呼ばれるフック・呼ばれないフック

フックサーバークライアント
setup()呼ばれる呼ばれる
onServerPrefetch呼ばれる呼ばれない
onBeforeMount呼ばれない呼ばれる
onMounted呼ばれない呼ばれる
onBeforeUpdate呼ばれない呼ばれる
onUpdated呼ばれない呼ばれる
onBeforeUnmount呼ばれない呼ばれる
onUnmounted呼ばれない呼ばれる
onActivated呼ばれない呼ばれる
onDeactivated呼ばれない呼ばれる

サーバー側ではDOMが存在しないため、DOM操作に関連するフック(onMounted以降)はすべてスキップされます。setup()とその中の同期コードのみがサーバーで実行されます。

onServerPrefetchによるサーバーデータ取得

onServerPrefetchはSSR専用のフックで、サーバーレンダリング時にPromiseを返す非同期処理を実行し、その完了を待ってからHTMLを描画します。

<script setup lang="ts">
import { ref, onServerPrefetch, onMounted } from 'vue'

interface Article {
  id: number
  title: string
  body: string
}

const article = ref<Article | null>(null)

// サーバー側: レンダリング前にデータ取得(高速)
onServerPrefetch(async () => {
  article.value = await fetchArticle()
})

// クライアント側: ハイドレーション時にデータがなければ取得
onMounted(async () => {
  if (!article.value) {
    article.value = await fetchArticle()
  }
})

async function fetchArticle(): Promise<Article> {
  const res = await fetch('https://api.example.com/articles/1')
  return res.json()
}
</script>

<template>
  <article v-if="article">
    <h1>{{ article.title }}</h1>
    <p>{{ article.body }}</p>
  </article>
  <p v-else>読み込み中...</p>
</template>

このパターンでは、サーバーでデータ取得を完了してからHTMLを返すため、クライアント側では初期表示の時点でデータが入った状態のHTMLが得られます。onMountedのガード条件(if (!article.value))は、クライアントサイドでのみ動的にレンダリングされるケース(ルーティング遷移後など)に対応するためのフォールバックです。

SSR環境での注意点

  • setup()内でブラウザAPIを呼ばない: windowdocumentlocalStorageなどはサーバーに存在しないため、参照するとエラーになります。これらの操作はonMounted内に限定します
  • 副作用の管理: setup()でsetIntervalやaddEventListenerを呼ぶと、サーバー側で解放されずリソースリークの原因になります
  • 条件分岐による防御: サーバーとクライアントで処理を分けたい場合はimport.meta.env.SSR(Vite環境)やtypeof window !== 'undefined'で判定します

よくあるミスとトラブルシューティング

1. onMounted内でtemplate refがnullになる

v-ifで条件付きレンダリングされている要素のrefは、条件がfalseの場合nullになります。

<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'

const isVisible = ref(false)
const elementRef = ref<HTMLDivElement | null>(null)

onMounted(() => {
  // isVisibleがfalseなら、elementRefはnull
  console.log(elementRef.value) // null
})

// v-ifの要素には、表示後にwatchで対応する
watch(isVisible, async (val) => {
  if (val) {
    await nextTick()
    console.log(elementRef.value) // <div>...</div>
  }
})
</script>

<template>
  <button @click="isVisible = !isVisible">切り替え</button>
  <div v-if="isVisible" ref="elementRef">条件付き要素</div>
</template>

2. onUpdatedでの無限ループ

onUpdated内でリアクティブデータを変更すると「データ変更 → DOM更新 → onUpdated → データ変更 → …」の無限ループが発生します。

<script setup lang="ts">
import { ref, onUpdated } from 'vue'

const count = ref(0)

// NG: 無限ループになる
onUpdated(() => {
  count.value++ // この変更がさらにDOMを更新し、onUpdatedが再び呼ばれる
})

// OK: 条件付きで変更するか、nextTickを使う
onUpdated(() => {
  // 条件を設けてループを防止
  if (count.value < 10) {
    count.value++
  }
})
</script>

根本的にはonUpdated内でのstate変更を避け、watchnextTickで代替するのがベストプラクティスです。

3. setupでのasync/awaitとライフサイクルフックの登録順

awaitの後に書いたライフサイクルフックは、コンポーネントインスタンスとの紐付けが切れる可能性があります。

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

// OK: awaitの前に登録
onMounted(() => {
  console.log('マウント完了')
})

const data = await fetch('/api/data') // トップレベルawait

// NG: awaitの後に登録すると紐付けが失われる可能性がある
onUnmounted(() => {
  console.log('これは呼ばれない可能性がある')
})
</script>

対策: すべてのライフサイクルフックはawaitよりも前に登録します。

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const data = ref(null)

// すべてのフックをawaitの前に登録
onMounted(() => {
  console.log('マウント完了')
})

onUnmounted(() => {
  console.log('アンマウント完了')
})

// フック登録後にawaitを実行
const res = await fetch('/api/data')
data.value = await res.json()
</script>

4. メモリリークの典型パターンと対処

メモリリークが起きやすいケースと対処法をまとめます。

パターンA: イベントリスナーの解除忘れ

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

const handleScroll = () => { /* ... */ }

onMounted(() => {
  // 登録
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  // 解除を忘れるとコンポーネント破棄後もリスナーが残る
  window.removeEventListener('scroll', handleScroll)
})
</script>

パターンB: クロージャによるコンポーネント参照の保持

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const largeData = ref<ArrayBuffer | null>(null)

onMounted(() => {
  largeData.value = new ArrayBuffer(1024 * 1024 * 10) // 10MB
})

onUnmounted(() => {
  // 大きなデータへの参照を明示的に解放
  largeData.value = null
})
</script>

パターンC: サードパーティライブラリのクリーンアップ

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// Chart.jsの例
import { Chart } from 'chart.js'

const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null

onMounted(() => {
  if (canvasRef.value) {
    chartInstance = new Chart(canvasRef.value, {
      type: 'bar',
      data: { /* ... */ },
    })
  }
})

onUnmounted(() => {
  // ライブラリ独自のdestroyメソッドを呼ぶ
  chartInstance?.destroy()
  chartInstance = null
})
</script>

<template>
  <canvas ref="canvasRef" />
</template>

5. 親子コンポーネントのフック発火順序

親と子のライフサイクルフックは以下の順序で発火します。

マウント時:

  1. 親のsetup()
  2. 親のonBeforeMount
  3. 子のsetup()
  4. 子のonBeforeMount
  5. 子のonMounted
  6. 親のonMounted

更新時:

  1. 親のonBeforeUpdate
  2. 子のonBeforeUpdate
  3. 子のonUpdated
  4. 親のonUpdated

アンマウント時:

  1. 親のonBeforeUnmount
  2. 子のonBeforeUnmount
  3. 子のonUnmounted
  4. 親のonUnmounted

つまり親のonMountedが呼ばれた時点では、すべての同期的な子コンポーネントのマウントが完了しています。親のonMounted内で子のDOM要素にアクセスしても問題ありません。

まとめ

Vue3のライフサイクルは、コンポーネントの「生成 → マウント → 更新 → アンマウント」という流れの各段階にフックを差し込める仕組みです。

  • setup()beforeCreate/created相当の初期化処理を担い、Composition APIの起点になります
  • onMounted はDOM操作・API通信・外部ライブラリ初期化の標準的なエントリポイントです
  • onUnmounted ではイベントリスナー・タイマー・接続のクリーンアップを必ず行い、メモリリークを防ぎます
  • onActivated / onDeactivated はKeepAliveと組み合わせて、キャッシュコンポーネントの表示・非表示イベントを捕捉します
  • onErrorCaptured で子孫のエラーを一括捕捉するError Boundaryパターンが実現できます
  • onRenderTracked / onRenderTriggered は開発モードで不要な再レンダリングの原因を特定するのに役立ちます
  • onServerPrefetch はSSR環境でサーバー側のデータプリフェッチを可能にします
  • watch / computed はライフサイクルフックではなくリアクティビティシステムの一部ですが、フックと補完的な関係にあります

各フックの公式リファレンスは Vue.js Composition API: ライフサイクルフック に詳しくまとめられています。ライフサイクルダイアグラムの図は ライフサイクルフック | Vue.js で確認できます。