こんにちは、かつコーチです。
前回は provide / inject によるProps drillingの解消を解説しました。
今回は、コンポーネントの「生まれてから消えるまで」の各タイミングにコードを差し込める、ライフサイクルフックを扱います。
「APIからデータを取得したいけど、どのタイミングで呼べばいいの?」という疑問は、ライフサイクルフックを理解すると一気に解消します。
ライフサイクルフックとは
コンポーネントには「一生」がある
Vueのコンポーネントは、画面に描画される前の準備段階から、実際に画面に表示され、最終的に破棄されるまで、いくつかの段階を経ています。
この一連の流れをライフサイクルと呼び、各段階の直前・直後に処理を差し込むための仕組みがライフサイクルフックです。
代表的なフック一覧
Composition APIで使う主なフックは次の通りです。
| フック | 実行タイミング |
|---|---|
onBeforeMount | DOMに描画される直前 |
onMounted | DOMに描画された直後 |
onBeforeUpdate | リアクティブなデータの変更でDOMが更新される直前 |
onUpdated | DOMの更新が終わった直後 |
onBeforeUnmount | コンポーネントが破棄される直前 |
onUnmounted | コンポーネントが破棄された直後 |
このうち実務で圧倒的によく使うのが onMounted と onUnmounted の2つです。
onMounted:DOM描画後に処理を実行する
基本の書き方
APIからデータを取得して画面に表示する、という典型的なケースで見てみましょう。
<script setup lang="ts">
import { ref, onMounted } from "vue"
interface User {
id: number
name: string
}
const users = ref<User[]>([])
const isLoading = ref<boolean>(true)
onMounted(async () => {
const response = await fetch("https://api.example.com/users")
users.value = await response.json()
isLoading.value = false
})
</script>
<template>
<p v-if="isLoading">読み込み中です</p>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>
onMounted の中は非同期関数にできるので、await を使ったAPI通信もそのまま書けます。
DOMが実際に存在している状態で実行されるため、document.querySelector などDOM操作が必要な処理もこのタイミングで安全に行えます。
なぜsetup直下に書かないのか
❌ Before:onMountedを使わずsetup直下でfetchする
<script setup lang="ts">
import { ref } from "vue"
const users = ref<[]>([])
// コンポーネントの初期化中に実行されてしまう
const response = await fetch("https://api.example.com/users")
users.value = await response.json()
</script>
一見動いてしまうこともあるのですが、これは <script setup> のトップレベルawaitという特殊な仕組みに依存した書き方で、実行タイミングの制御がしづらく、テストコードからも扱いにくくなります。
私が実際につまずいたのは、この書き方でローディング状態の管理が破綻したケースでした。
「DOMが構築される前に通信が始まる」という前提で書いていたコンポーネントを、別の場所から動的にインポート(defineAsyncComponent)する形に変更したところ、読み込みのタイミングがずれて画面がちらつく不具合が発生したのです。
✅ After:onMountedの中に処理をまとめる
<script setup lang="ts">
import { ref, onMounted } from "vue"
const users = ref<[]>([])
const isLoading = ref<boolean>(true)
onMounted(async () => {
const response = await fetch("https://api.example.com/users")
users.value = await response.json()
isLoading.value = false
})
</script>
onMounted に処理をまとめておけば、「DOM描画後」という実行タイミングが明示され、どんな読み込まれ方をしても意図通りに動くようになりました。
処理の起点が1箇所に決まっているというのは、後からコードを読む人にとっても分かりやすい状態です。
onUnmounted:後片付けを忘れないための場所
タイマーやイベントリスナーの解除
setInterval や addEventListener のように、コンポーネントの外側のリソースを使う処理は、破棄されるタイミングで必ず片付ける必要があります。
❌ Before:後片付けをしない
<script setup lang="ts">
import { onMounted, ref } from "vue"
const seconds = ref<number>(0)
onMounted(() => {
setInterval(() => {
seconds.value++
}, 1000)
})
</script>
このコンポーネントが何度も表示・非表示を繰り返されると、setInterval がどんどん増え続けてしまいます。
私はこのパターンで、画面を数分放置しただけでブラウザのタブが重くなるという不具合を経験しました。
原因を調べてみると、モーダルを開閉するたびに同じタイマーが二重・三重に動き続けていたのです。
✅ After:onUnmountedで解除する
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue"
const seconds = ref<number>(0)
let timerId: ReturnType<typeof setInterval> | undefined
onMounted(() => {
timerId = setInterval(() => {
seconds.value++
}, 1000)
})
onUnmounted(() => {
clearInterval(timerId)
})
</script>
onMounted で始めた処理は、原則として対になる onUnmounted で終わらせる、とセットで考える癖をつけると事故を防げます。
addEventListener を使った場合も同様に、onUnmounted で removeEventListener を呼ぶようにしましょう。
複数のライフサイクルフックを組み合わせる
監視とライフサイクルを併用する
watch と組み合わせて、値の変化に応じて後片付けを行うパターンもよく使います。
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue"
const isVisible = ref<boolean>(false)
function handleScroll(): void {
isVisible.value = window.scrollY > 100
}
onMounted(() => {
window.addEventListener("scroll", handleScroll)
})
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll)
})
</script>
handleScroll という同じ関数を登録・解除の両方で使うことで、「登録した処理と解除した処理が食い違う」というありがちなミスも防げます。
まとめ
この記事のポイント
- ライフサイクルフックは、コンポーネントの各段階に処理を差し込む仕組み
- API通信やDOM操作は
onMountedの中で行うと、実行タイミングが明確になる setIntervalやaddEventListenerを使ったら、onUnmountedでの後片付けを必ずセットで書く- 登録と解除は同じ関数を使い回すと、対応関係が崩れにくい
次に読むべき記事
ライフサイクルフックを使った処理は、複数のコンポーネントで似たようなロジックが繰り返されがちです。
次回は、そうしたロジックを再利用可能な形に切り出す「コンポーザブル」の自作方法を解説します。
→ 次の記事:コンポーザブル(Composable)を自作してロジックを再利用する