nextTickとは、リアクティブな値を変えたあと、Vue が DOM を更新し終えるのを待つための関数です。 Vue は値を変えた瞬間には DOM を書き換えず、同じタイミングで行われた変更をまとめて一度に反映します。そのため、値を変えた直後に DOM を読むと変更前のままで、await nextTick() のあとに読むと新しい内容になっています。

この記事では、値を変えた直後・await nextTick() の後・requestAnimationFrame の後で DOM がどう違うかを実際に出力して並べます。さらに、入力欄へのフォーカスや末尾へのスクロールなど場面別のコード、「await nextTick() を書いたのに反映されない」ときの原因一覧、watch の flush・onUpdated・テストでの flushPromises との使い分けまでをまとめました。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / Vite 8.3.2(@vitejs/plugin-vue 6.0.9)でビルドし、Playwright 1.56.0 の Chromium 141 で実行。テストは Vitest 5.0.3 / @vue/test-utils 2.5.1 / jsdom 30.1.1。

nextTickの早見表

やりたいこと 書き方 ポイント
値を変えたあとの DOM を読む count.value++ → await nextTick() → DOM を読む 必ず「変更 → nextTick」の順
v-if で出した入力欄にフォーカス editing.value = true → await nextTick() → input.value.focus() 直後はテンプレート参照が null
リストに追加して末尾までスクロール push → await nextTick() → scrollTop = scrollHeight 待たないと1件分だけ足りない
増えた分を含む高さを測る 変更 → await nextTick() → offsetHeight 直後は変更前の高さ
値が変わるたびに更新後の DOM で処理する watch(source, fn, { flush: 'post' }) ボタンごとに nextTick を書かずに済む
コンポーネントが更新されるたびに処理する onUpdated(fn) どの値の変更かは区別しない
テストで取得処理の完了を待つ await flushPromises() nextTick は DOM 更新だけを待つ

待ち方ごとに、何を待てるかを整理します。

待ち方 待てるもの 待てないもの
await nextTick() 直前までの変更による DOM 更新 通信、タイマー、画像の読み込み、アニメーションの終了
watch の flush: 'post' 監視している値の変更による DOM 更新 同上
onUpdated そのコンポーネントの DOM 更新 同上
flushPromises()(テスト用) すでに解決した Promise の後続処理と、その結果の DOM 更新 タイマー(フェイクタイマーを進める)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue nextTickの使い方|DOM更新が反映されない原因」

nextTickの基本の使い方

nextTick は vue から import して使います。公式の API リファレンスでは、型は function nextTick(callback?: () => void): Promise<void> と示されています。Promise を返すので await でき、コールバックを渡す書き方もできます。

<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
const el = ref(null)

async function increment() {
  count.value++
  console.log('値を変えた直後       : ' + el.value.textContent)
  await nextTick()
  console.log('await nextTick の後  : ' + el.value.textContent)
  requestAnimationFrame(() => {
    console.log('requestAnimationFrame: ' + el.value.textContent)
  })
}
</script>

<template>
  <button @click="increment">+1</button>
  <p ref="el">{{ count }}</p>
</template>

ボタンを2回押したときの出力です。

値を変えた直後       : 0
await nextTick の後  : 1
requestAnimationFrame: 1
値を変えた直後       : 1
await nextTick の後  : 2
requestAnimationFrame: 2

値を変えた直後の DOM は、まだ変更前の内容です。 count.value 自体はすぐに 1 になっていますが、画面に対応する DOM は await nextTick() を過ぎてから新しくなります。requestAnimationFrame の中でも新しい内容が読めていますが、これは nextTick より後のタイミングだからです。DOM を読みたいだけなら nextTick で足ります。

公式リファレンスは、この動きを次のように説明しています。Vue でリアクティブな状態を変えても、DOM の更新は同期的には行われない。状態を何回変えても各コンポーネントの更新が1回で済むよう、「次の tick」まで更新をためておく、という内容です。

DOM更新がまとめて行われる仕組み

3回変えても再レンダーは1回

「まとめて反映する」ことを、再レンダーの回数で確かめました。

<script setup>
import { ref, nextTick, onUpdated } from 'vue'
const a = ref(0)
const b = ref(0)
const c = ref(0)
let renders = 0
let updates = 0
const countRender = () => { renders++; return '' }
onUpdated(() => { updates++ })

async function together() {
  renders = 0; updates = 0
  a.value++
  b.value++
  c.value++
  await nextTick()
  console.log(`まとめて変更    : 再レンダー ${renders} 回 / onUpdated ${updates} 回`)
}
async function oneByOne() {
  renders = 0; updates = 0
  a.value++
  await nextTick()
  b.value++
  await nextTick()
  c.value++
  await nextTick()
  console.log(`1つずつ待って変更: 再レンダー ${renders} 回 / onUpdated ${updates} 回`)
}
</script>

<template>
  <button @click="together">まとめて</button>
  <button @click="oneByOne">1つずつ</button>
  <p>{{ a }} {{ b }} {{ c }}{{ countRender() }}</p>
</template>
まとめて変更    : 再レンダー 1 回 / onUpdated 1 回
1つずつ待って変更: 再レンダー 3 回 / onUpdated 3 回

続けて3つの値を変えても、再レンダーは1回だけです。変更のたびに DOM を書き換えるのではなく、「更新が必要」という予約だけを入れておき、いま動いている処理が終わってからまとめて反映しているためです。途中に await nextTick() を挟むと、そのつど反映されるので3回になります。

sequenceDiagram
    participant C as 自分のコード
    participant Q as Vueの更新キュー
    participant D as DOM
    C->>Q: a.value++(更新を予約)
    C->>Q: b.value++(予約済みなので増えない)
    C->>Q: c.value++(予約済みなので増えない)
    C->>Q: await nextTick()(更新の後ろに並ぶ)
    Note over C: 同期処理がここで終わる
    Q->>D: まとめて1回だけ再レンダー
    Q->>C: nextTick の続きを再開
    Note over C: ここで DOM は新しい内容

「変更より後に呼ぶ」ことが大事

いろいろな待ち方を1つの関数に並べ、それぞれの時点で DOM がどうなっているかを記録しました。

<script setup>
import { ref, nextTick } from 'vue'
const count = ref(0)
const el = ref(null)
const log = (m) => console.log(m + ' → DOM=' + el.value.textContent)

function run() {
  Promise.resolve().then(() => log('A 変更より前に登録した Promise.then'))
  nextTick(() => log('B 変更より前に呼んだ nextTick'))
  count.value++
  log('C 変更直後(同期)')
  nextTick(() => log('D 変更より後に呼んだ nextTick'))
  Promise.resolve().then(() => log('E 変更より後に登録した Promise.then'))
  setTimeout(() => log('F setTimeout 0'))
  requestAnimationFrame(() => log('G requestAnimationFrame'))
}
</script>

<template>
  <button @click="run">実行</button>
  <p ref="el">{{ count }}</p>
</template>
C 変更直後(同期)                  → DOM=0
A 変更より前に登録した Promise.then → DOM=0
B 変更より前に呼んだ nextTick       → DOM=0
E 変更より後に登録した Promise.then → DOM=1
D 変更より後に呼んだ nextTick       → DOM=1
G requestAnimationFrame             → DOM=1
F setTimeout 0                      → DOM=1
記号 書いた位置 DOM 読み取れること
C 変更の直後(同期) 変更前 同期処理の中では DOM は変わらない
A・B 値を変えるより前 変更前 先に登録した処理は、DOM 更新より先に動く
D・E 値を変えた後 変更後 更新の予約より後ろに並ぶので、新しい DOM が読める
F・G どこでも 変更後 同期処理と、その直後に続く処理がすべて終わってから動く

nextTick は「値を変えた後」に呼ぶ、というのがいちばんの注意点です。B のように、変更より前にコールバックを渡してしまうと、DOM 更新より先に動いて変更前の内容を読みます。関数の最初にまとめて nextTick(() => ...) を書いている場合は、変更の後ろへ移します。

場面別のコピペ用コード

v-ifで表示した入力欄にフォーカスする

<script setup>
import { ref, nextTick } from 'vue'
const editing = ref(false)
const input = ref(null)

async function startEdit() {
  editing.value = true
  console.log('直後の input         : ' + input.value)
  await nextTick()
  input.value.focus()
  console.log('nextTick 後のフォーカス: ' + document.activeElement.tagName)
}
</script>

<template>
  <button @click="startEdit">編集する</button>
  <input v-if="editing" ref="input" value="タイトル" />
</template>
直後の input         : null
nextTick 後のフォーカス: INPUT

v-if の要素は、true にした直後にはまだ DOM に存在しません。テンプレート参照が null なので、await nextTick() を挟まずに input.value.focus() と書くと、null に対して focus を呼ぶことになりエラーで止まります。

リストに追加して末尾までスクロールする

<script setup>
import { ref, nextTick } from 'vue'
const messages = ref(Array.from({ length: 10 }, (_, i) => 'メッセージ ' + (i + 1)))
const box = ref(null)
const rest = () => box.value.scrollHeight - box.value.scrollTop - box.value.clientHeight

function addWithoutTick() {
  messages.value.push('新着(待たない)')
  box.value.scrollTop = box.value.scrollHeight
  setTimeout(() => console.log('待たずにスクロール     : 末尾まで残り ' + rest() + 'px'))
}
async function addWithTick() {
  messages.value.push('新着(待つ)')
  await nextTick()
  box.value.scrollTop = box.value.scrollHeight
  setTimeout(() => console.log('nextTick 後にスクロール: 末尾まで残り ' + rest() + 'px'))
}
</script>

<template>
  <button @click="addWithoutTick">待たずに追加</button>
  <button @click="addWithTick">待って追加</button>
  <div ref="box" class="chat">
    <p v-for="(m, i) in messages" :key="i">{{ m }}</p>
  </div>
</template>

<style>
.chat {
  height: 120px;
  overflow-y: auto;
}
.chat p {
  margin: 0;
  height: 24px;
}
</style>
待たずにスクロール     : 末尾まで残り 24px
nextTick 後にスクロール: 末尾まで残り 0px

待たずにスクロールすると、追加した1件分(24px)だけ届きません。 スクロール位置を決めた時点の scrollHeight に、新しい要素の高さがまだ含まれていないためです。「いつも最後の1件が隠れる」という症状は、これが理由です。

増えたあとの要素の高さを測る

<script setup>
import { ref, nextTick } from 'vue'
const lines = ref(['1行目'])
const box = ref(null)

async function addLines() {
  lines.value.push('2行目', '3行目')
  console.log('直後の高さ       : ' + box.value.offsetHeight + 'px')
  await nextTick()
  console.log('nextTick 後の高さ: ' + box.value.offsetHeight + 'px')
}
</script>

<template>
  <button @click="addLines">行を追加</button>
  <div ref="box" class="hbox">
    <p v-for="l in lines" :key="l">{{ l }}</p>
  </div>
</template>

<style>
.hbox p {
  margin: 0;
  height: 24px;
}
</style>
直後の高さ       : 24px
nextTick 後の高さ: 72px

高さや幅、位置(getBoundingClientRect())を使う処理は、どれも同じ形で書けます。

Transitionが始まる前の要素を触る

Transition で包んだ要素を v-if で表示したとき、nextTick の時点で要素がどういう状態かを確かめました。

<script setup>
import { ref, nextTick } from 'vue'
const show = ref(false)
const panel = ref(null)
const log = (m) => console.log(m)

async function open() {
  show.value = true
  await nextTick()
  log('nextTick 後 : class="' + panel.value.className + '" 幅=' + panel.value.offsetWidth + 'px')
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      log('2フレーム後 : class="' + panel.value.className + '"')
    })
  })
}
</script>

<template>
  <button @click="open">開く</button>
  <Transition name="fade" @after-enter="log('after-enter : class=&quot;' + panel.className + '&quot;')">
    <div v-if="show" ref="panel" style="width: 200px">パネル</div>
  </Transition>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>
nextTick 後 : class="fade-enter-from fade-enter-active" 幅=200px
2フレーム後 : class="fade-enter-active fade-enter-to"
after-enter : class=""

nextTick の時点で、要素はすでに DOM に入っていて寸法も測れますが、アニメーションは始まる前の状態(enter-from が付いた状態)です。 位置合わせのように「見える前に済ませたい処理」は nextTick の後に、フォーカスの移動のように「現れ終わってから行いたい処理」は @after-enter に書くと、ちょうどよいタイミングになります。

await nextTickしても反映されない原因の一覧

nextTick が待つのは、呼んだ時点までの変更による DOM 更新だけです。それ以外のものを待つ必要がある場面を、実際に動かして確かめました。

原因 nextTick 後の状態(実測) 待つべきもの・直し方
nextTick を値の変更より前に呼んでいる コールバックの中で DOM は変更前 変更の後に呼ぶ
リアクティブでない変数を変えている DOM は変わらない ref / reactive にする
v-if で出した子が、データを非同期に取得している 子は表示されるが、中身は 0 件 子から emit で完了を知らせる
非同期コンポーネント(defineAsyncComponent) 要素が null @vue:mounted などマウント完了の合図を待つ
Transition の mode="out-in" で切り替え中 次の要素が null @enter / @after-enter で処理する
画像の読み込み前 画像の高さが 0px @load か img.decode() を待つ
Teleport で別の場所に描画している 親要素の中を探しても null テンプレート参照を使う

リアクティブでない変数を変えている

<script setup>
import { ref, nextTick } from 'vue'
let plain = 0
const reactive = ref(0)
const el = ref(null)

async function run() {
  plain++
  await nextTick()
  console.log('ただの変数を変更 : DOM=' + el.value.textContent.trim() + ' / 変数=' + plain)
  reactive.value++
  await nextTick()
  console.log('ref を変更       : DOM=' + el.value.textContent.trim())
}
</script>

<template>
  <button @click="run">実行</button>
  <p ref="el">{{ plain }} / {{ reactive }}</p>
</template>
ただの変数を変更 : DOM=0 / 0 / 変数=1
ref を変更       : DOM=1 / 1

let で宣言しただけの変数は、変えても Vue に伝わらないので、何回 nextTick を待っても DOM は変わりません。2行目で plain の表示も 1 になっているのは、ref の変更で再レンダーが起き、そのときの plain の値が読まれたためです。「別の操作をすると急に反映される」ときは、この形を確認します。

v-ifで出した子コンポーネントがデータを取得している

子コンポーネントが onMounted でデータを取得する場合です。

<!-- ChildAsync.vue -->
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const emit = defineEmits(['ready'])
const items = ref([])
const load = () => new Promise((r) => setTimeout(() => r(['A', 'B', 'C']), 50))

onMounted(async () => {
  items.value = await load()
  await nextTick()
  emit('ready')
})
</script>

<template>
  <ul><li v-for="i in items" :key="i">{{ i }}</li></ul>
</template>
<!-- 親 -->
<script setup>
import { ref, nextTick } from 'vue'
import ChildAsync from './ChildAsync.vue'
const showAsync = ref(false)
const area = ref(null)
const count = () => area.value.querySelectorAll('li').length

async function openAsync() {
  showAsync.value = true
  await nextTick()
  console.log('nextTick 1回後: li ' + count() + ' 個')
  await nextTick()
  console.log('nextTick 2回後: li ' + count() + ' 個')
}
function onReady() {
  console.log('子が ready を emit したとき: li ' + count() + ' 個')
}
</script>

<template>
  <button @click="openAsync">子を表示</button>
  <div ref="area">
    <ChildAsync v-if="showAsync" @ready="onReady" />
  </div>
</template>
nextTick 1回後: li 0 個
nextTick 2回後: li 0 個
子が ready を emit したとき: li 3 個

nextTick を2回、3回と重ねても、通信やタイマーの完了は待てません。 子の中で「取得 → 代入 → nextTick」まで済ませてから emit で知らせると、親は確実に描画後の DOM を扱えます。

なお、子が onMounted の中で同期的に items.value = ['A', 'B', 'C'] と代入するだけの場合は、親の nextTick 1回で li が3個そろっていました。待てないのは、あくまで非同期の処理です。

非同期コンポーネントを表示した直後

<script setup>
import { ref, nextTick, defineAsyncComponent } from 'vue'
const Heavy = defineAsyncComponent(() => import('./Heavy.vue'))
const show = ref(false)
const area = ref(null)

async function open() {
  show.value = true
  await nextTick()
  console.log('nextTick 後        : ' + area.value.querySelector('.heavy'))
}
function onMountedHeavy() {
  console.log('@vue:mounted のとき: ' + area.value.querySelector('.heavy'))
}
</script>

<template>
  <button @click="open">表示</button>
  <div ref="area">
    <Heavy v-if="show" @vue:mounted="onMountedHeavy" />
  </div>
</template>
nextTick 後        : null
@vue:mounted のとき: [object HTMLDivElement]

defineAsyncComponent で読み込むコンポーネントは、ファイルの読み込みが終わってから描画されます。nextTick の時点ではまだ読み込み中なので、要素はありません。今回は @vue:mounted でマウント完了を受け取りましたが、前の例のように子から emit する形でも同じように扱えます。

Transitionのmode="out-in"で切り替え中

<script setup>
import { ref, nextTick } from 'vue'
const step = ref(1)
const area = ref(null)

async function next() {
  step.value = 2
  await nextTick()
  console.log('nextTick 後  : ' + area.value.querySelector('.step2') + ' / 表示中=' + area.value.textContent.trim())
}
function onEnter(el) {
  console.log('@enter のとき: ' + area.value.querySelector('.step2') + ' / 表示中=' + area.value.textContent.trim())
}
</script>

<template>
  <button @click="next">次へ</button>
  <div ref="area">
    <Transition name="fade" mode="out-in" @enter="onEnter">
      <p v-if="step === 1" key="1" class="step1">手順1</p>
      <p v-else key="2" class="step2">手順2</p>
    </Transition>
  </div>
</template>
nextTick 後  : null / 表示中=手順1
@enter のとき: [object HTMLParagraphElement] / 表示中=手順2

mode="out-in" では、前の要素が消え終わってから次の要素が DOM に入ります。nextTick の時点では、まだ「手順1」が消えている途中です。次の要素に対する処理は、Transition の @enter(挿入された直後)か @after-enter(現れ終わった後)に書きます。フックの引数 el に、その要素が渡されます。

画像の読み込み前に高さを測っている

<script setup>
import { ref, nextTick } from 'vue'
const show = ref(false)
const url = '/photo.png' // 縦120pxの画像
const img = ref(null)

async function open() {
  show.value = true
  await nextTick()
  console.log('nextTick 後      : 高さ ' + img.value.offsetHeight + 'px(complete=' + img.value.complete + ')')
  await img.value.decode()
  console.log('img.decode() の後: 高さ ' + img.value.offsetHeight + 'px')
}
function onLoad() {
  console.log('@load のとき     : 高さ ' + img.value.offsetHeight + 'px')
}
</script>

<template>
  <button @click="open">画像を表示</button>
  <img v-if="show" ref="img" :src="url" alt="" @load="onLoad" />
</template>
nextTick 後      : 高さ 0px(complete=false)
@load のとき     : 高さ 120px
img.decode() の後: 高さ 120px

img 要素そのものは nextTick の時点で DOM にありますが、画像ファイルが届くまで高さは 0px です。画像を含むリストで「末尾までスクロールしたのに少し足りない」ときは、これが原因です。@load イベントで測り直すか、await img.decode() で読み込みを待ちます。

Teleportで別の場所に描画している

<script setup>
import { ref, nextTick } from 'vue'
const open = ref(false)
const root = ref(null)
const modal = ref(null)

async function show() {
  open.value = true
  await nextTick()
  console.log('root.querySelector(".modal")    : ' + root.value.querySelector('.modal'))
  console.log('document.querySelector(".modal"): ' + document.querySelector('.modal'))
  console.log('テンプレート参照 modal.value    : ' + modal.value)
}
</script>

<template>
  <div ref="root">
    <button @click="show">開く</button>
    <Teleport to="body">
      <div v-if="open" ref="modal" class="modal">モーダル</div>
    </Teleport>
  </div>
</template>
root.querySelector(".modal")    : null
document.querySelector(".modal"): [object HTMLDivElement]
テンプレート参照 modal.value    : [object HTMLDivElement]

Teleport の中身は、テンプレート上の位置ではなく to で指定した場所に描画されます。DOM 自体は nextTick の時点でできていますが、親要素の中を querySelector で探しても見つかりません。 テンプレート参照(ref)なら、描画先がどこでも要素を取得できます。

もう1つ、移動先の要素を同じコンポーネントのテンプレートで後から描画している場合です。

<script setup>
import { ref, nextTick } from 'vue'
const show = ref(false)

async function open() {
  show.value = true
  await nextTick()
  console.log('nextTick 後の #late の中身: "' + (document.querySelector('#late')?.textContent ?? '') + '"')
}
</script>

<template>
  <button @click="open">開く</button>
  <template v-if="show">
    <Teleport to="#late">
      <span>ここに出したい</span>
    </Teleport>
    <div id="late"></div>
  </template>
</template>

この書き方では、開発ビルドで次の警告が出て、中身は描画されませんでした。

[Vue warn] Failed to locate Teleport target with selector "#late". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.

Teleport に defer を付けて <Teleport defer to="#late"> と書くと、同じ更新の中で後から描画される要素も移動先にできます。defer を付けた版の出力は次のとおりです。

nextTick 後の #late の中身: "ここに出したい"

watchのflush・onUpdatedとの使い分け

ボタンごとに await nextTick() を書く代わりに、「値が変わったら、更新後の DOM で処理する」と宣言しておく方法もあります。それぞれがいつ動き、そのとき DOM がどうなっているかを記録しました。

<script setup>
import { ref, watch, nextTick, onUpdated } from 'vue'
const count = ref(0)
const el = ref(null)
const log = (m) => console.log(m + ' 値=' + count.value + ' DOM=' + el.value.textContent)

watch(count, () => log('watch(既定=pre)'))
watch(count, () => log('watch(flush: post)'), { flush: 'post' })
watch(count, () => log('watch(flush: sync)'), { flush: 'sync' })
onUpdated(() => log('onUpdated'))

function run() {
  count.value++
  count.value++
  nextTick(() => log('nextTick のコールバック'))
  log('同期(変更直後)')
}
</script>

<template>
  <button @click="run">+2</button>
  <p ref="el">{{ count }}</p>
</template>
watch(flush: sync)    値=1 DOM=0
watch(flush: sync)    値=2 DOM=0
同期(変更直後)        値=2 DOM=0
watch(既定=pre)      値=2 DOM=0
watch(flush: post)    値=2 DOM=2
onUpdated               値=2 DOM=2
nextTick のコールバック 値=2 DOM=2
方法 動くタイミング DOM 2回続けて変えたとき 向いている場面
watch(既定) DOM 更新の前 変更前 1回にまとまる 値に応じて別の値を変える処理
watch の flush: 'post' DOM 更新の後 変更後 1回にまとまる 特定の値が変わったあとの DOM 操作(スクロールなど)
watch の flush: 'sync' 値を変えた瞬間 変更前 変更のたびに動く(2回) 変更を1つずつ同期的に拾う必要があるとき
onUpdated DOM 更新の後 変更後 1回にまとまる どの値かを問わず、更新のたびに行う処理
nextTick DOM 更新の後 変更後 呼んだ回数だけ その場の1回きりの処理(クリック時のフォーカスなど)

公式ガイドの説明も、この結果と一致しています。既定では、ウォッチャーのコールバックは親コンポーネントの更新の後、そのコンポーネント自身の DOM 更新の前に呼ばれます。更新後の DOM に触りたいときは flush: 'post' を指定し、watchEffect 用には watchPostEffect() という別名が用意されています。flush: 'sync' はまとめ処理が行われないため、配列のように頻繁に変わるデータでは使う場面を選ぶように、という注意も書かれています。

同じ処理があちこちのボタンに await nextTick() つきで書かれているなら、watch の flush: 'post' に1か所だけ書くと、すっきりします。たとえば先ほどの「末尾までスクロール」は、次のように書けます。

watch(
  () => messages.value.length,
  () => { box.value.scrollTop = box.value.scrollHeight },
  { flush: 'post' }
)

addWithoutTick のように push するだけの関数に変えてこの watch を足したところ、追加のたびに「末尾まで残り 0px」になりました。

テスト(Vitest)でのnextTickとflushPromises

コンポーネントのテストでも、DOM 更新を待つ場面が出てきます。Vue Test Utils の flushPromises との違いを、実際のテストで確かめました。

<!-- Profile.vue:マウント時に取得する -->
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({ load: Function })
const name = ref('')

onMounted(async () => {
  const user = await props.load()
  name.value = user.name
})
</script>

<template>
  <p>{{ name || '読み込み中' }}</p>
</template>
<!-- Counter.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>
<!-- Search.vue:入力から300ms後に反映する -->
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const applied = ref('')
let timer
function onInput(e) {
  keyword.value = e.target.value
  clearTimeout(timer)
  timer = setTimeout(() => { applied.value = keyword.value }, 300)
}
</script>

<template>
  <input :value="keyword" @input="onInput" />
  <p>検索語: {{ applied || 'なし' }}</p>
</template>
// test/tick.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import Profile from './Profile.vue'
import Counter from './Counter.vue'
import Search from './Search.vue'

describe('nextTick と flushPromises の違い', () => {
  it('クリック後は trigger を await すれば DOM が更新されている', async () => {
    const wrapper = mount(Counter)
    wrapper.find('button').trigger('click')
    console.log('await なし        :', wrapper.text())
    await nextTick()
    console.log('await nextTick 後 :', wrapper.text())
    await wrapper.find('button').trigger('click')
    console.log('await trigger 後  :', wrapper.text())
    expect(wrapper.text()).toBe('2')
  })

  it('Promise を1つ待つだけの取得', async () => {
    const load = () => Promise.resolve({ name: '田中' })
    const wrapper = mount(Profile, { props: { load } })
    console.log('mount 直後            :', wrapper.text())
    await nextTick()
    console.log('await nextTick 後     :', wrapper.text())
    await flushPromises()
    console.log('await flushPromises 後:', wrapper.text())
    expect(wrapper.text()).toBe('田中')
  })

  it('setTimeout を使う処理はフェイクタイマーを進める', async () => {
    vi.useFakeTimers()
    const wrapper = mount(Search)
    await wrapper.find('input').setValue('vue')
    await flushPromises()
    console.log('flushPromises だけ    :', wrapper.find('p').text())
    vi.advanceTimersByTime(300)
    console.log('タイマーを進めた直後  :', wrapper.find('p').text())
    await nextTick()
    console.log('その後 await nextTick :', wrapper.find('p').text())
    expect(wrapper.find('p').text()).toBe('検索語: vue')
    vi.useRealTimers()
  })
})

設定ファイルは次のとおりです。

// vitest.config.mjs
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: { environment: 'jsdom', include: ['test/**/*.test.js'] },
})

npx vitest run の出力(要点)です。

await なし        : 0
await nextTick 後 : 1
await trigger 後  : 2
mount 直後            : 読み込み中
await nextTick 後     : 読み込み中
await flushPromises 後: 田中
flushPromises だけ    : 検索語: なし
タイマーを進めた直後  : 検索語: なし
その後 await nextTick : 検索語: vue

 Test Files  1 passed (1)
テストで待ちたいもの 書き方 実測でわかったこと
クリックや入力による DOM 更新 await wrapper.find(...).trigger('click') trigger を await するだけで更新後になる
手動で値を変えたあとの DOM 更新 await nextTick() 更新は待てるが、取得の完了は待てない
モックした取得処理の完了と、その反映 await flushPromises() 「読み込み中」から取得後の表示に変わる
setTimeout を使う処理 vi.advanceTimersByTime(ms) のあと await nextTick() タイマーを進めた直後は値が変わっただけで、DOM は nextTick 後に変わる

Profile の例では、await nextTick() を1回待っても「読み込み中」のままでした。await が何段も続く取得処理(応答を受け取ってから本文を取り出すような形)でも試しましたが、nextTick を2回待っても「読み込み中」のままで、flushPromises() で取得後の表示になりました。取得処理をモックしたテストでは flushPromises()、タイマーが絡むなら フェイクタイマー、と待つ相手に合わせて選ぶのが確実です。

よくある質問

Q. nextTickは「コールバック」と「await」のどちらで書くのがよいですか? A. どちらでも同じタイミングで動きます。await nextTick() のほうが処理を上から順に読めるので、async 関数の中ではこちらが書きやすいでしょう。どちらの場合も、値を変えた後に呼ぶことが大切です。

Q. setTimeout(fn, 0) で代用できますか? A. 今回の計測では、setTimeout 0 のコールバックの時点でも DOM は更新済みでした。ただし setTimeout は nextTick より後に動くので、その分だけ処理が遅れます。DOM 更新を待つ目的なら、意図が伝わりやすい nextTick を使います。

Q. await nextTick() を2回書くと直ることがあるのはなぜですか? A. 1回目の nextTick の後に、別の処理がさらに値を変えていると、もう一度更新が行われるためです。ただし、今回の計測では、通信やタイマーを伴う処理は nextTick を2回重ねても待てませんでした。回数で合わせるのではなく、待ちたい相手が何かを確かめ、emit やイベント、watch の flush: 'post' など、その完了を直接受け取れる方法に置き換えると安定します。

Q. nextTickの中でさらに値を変えるとどうなりますか? A. 新しい更新が予約され、もう一度まとめて反映されます。再レンダーの回数を数えた例のとおり、await nextTick() を挟むたびに1回ずつ再レンダーが行われるので、続けて変えられるものは nextTick の前にまとめて変えておくと効率的です。

まとめ

  • nextTick は、値を変えたあと Vue が DOM を更新し終えるのを待つ関数です。値を変えた直後の DOM は、まだ変更前の内容です。
  • Vue は同じタイミングの変更をまとめて1回の再レンダーで反映します。3つの値を続けて変えても、再レンダーは1回でした。
  • nextTick は必ず値を変えた後に呼びます。 変更より前に渡したコールバックは、DOM 更新より先に動きます。
  • フォーカス・末尾へのスクロール・高さの取得は、「変更 → await nextTick() → DOM 操作」の形で書けます。
  • nextTick が待つのは DOM 更新だけです。通信、非同期コンポーネント、Transition の切り替え、画像の読み込みは、それぞれの完了の合図(emit、@vue:mounted、@enter、@load)を待ちます。
  • 値が変わるたびに行う DOM 操作は、watch の flush: 'post' にまとめると、ボタンごとの nextTick が要らなくなります。
  • テストでは、DOM 更新は await trigger() か nextTick、取得の完了は flushPromises、タイマーはフェイクタイマーと使い分けます。

参考・出典

  • Vue.js 公式 API リファレンス「nextTick()」 https://ja.vuejs.org/api/general#nexttick
  • Vue.js 公式ガイド「ウォッチャー」 https://ja.vuejs.org/guide/essentials/watchers

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue nextTickの使い方|DOM更新が反映されない原因」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?