If you need your watcher to run after the DOM has updated, you turn to a watcher with flush: 'post'
:
const items = ref([])function addItem(newItem) {items.value.push(newItem)}watch(items, () => {// The DOM has been updated now// Do something with the updated list of items}, { flush: 'post' })
But we also have the nextTick
function. The docs state that nextTick
is “A utility for waiting for the next DOM update flush.” So it appears that it does exactly the same thing.
How are these two approaches different, and does it matter for your code?
The short answer: it’s a single microtask, and it doesn’t matter.
The long answer continues below.
These two implementations will both work, and they’ll both give you the same result (most of the time):
const items = ref([])function addItem(newItem) {items.value.push(newItem)}// 1. Using a post flush watcherwatch(items, () => {// The DOM has been updated now// Do something with the updated list of items}, { flush: 'post' })// 2. Using a pre flush watcher with nextTickwatch(items, async () => {// The watcher gets called before the DOM updates// But we can wait by using nextTickawait nextTick()// The DOM has been updated now// Do something with the updated list of items})
These two approaches are basically equivalent. By sticking the nextTick
inside of the watcher, it becomes even more clear how similar the code ends up becoming.
If we’re only mutating the items
ref inside of the addItem
function, we don’t need a watcher at all and we could further simplify it by just using nextTick
there:
const items = ref([])async function addItem(newItem) {items.value.push(newItem)await nextTick()// The DOM has been updated now// Do something with the updated list of items}
Although nearly identical, there is a slight difference based on how Vue treats post flush watchers and nextTick
effects.
Let’s take a look at the implementation of nextTick
itself:
export function nextTick<T = void, R = void>(this: T,fn?: (this: T) => R,): Promise<Awaited<R>> {const p = currentFlushPromise || resolvedPromisereturn fn ? p.then(this ? fn.bind(this) : fn) : p}
We can remove some TypeScript noise to simplify it:
export function nextTick(this, fn) {const p = currentFlushPromise || resolvedPromisereturn fn ? p.then(this ? fn.bind(this) : fn) : p}
The this
value is bound to the component instance when it’s used with the Options API. Otherwise, it’s not bound because we don’t use this
in the Compositions API like we used to.
We can use nextTick
in two different ways:
// With awaitawait nextTick()// Passing in a functionnextTick(() => {})
To make the implementation clearer, I’ll rewrite nextTick
in both of these ways, and remove the support for the Options API as well:
// With awaitexport function nextTick() {return currentFlushPromise || resolvedPromise}// Passing in a functionexport function nextTick(fn) {const p = currentFlushPromise || resolvedPromisereturn p.then(fn)}
Now we can see that nextTick
is basically just returning the currentFlushPromise
. If you pass in a function though, it has to do some extra work to run that function once the currentFlushPromise
resolves.
So, in our components, we can think of nextTick
as being this:
await currentFlushPromise
Of course, now we need to figure out what currentFlushPromise
does, and you’d be right in thinking it’s somehow related to the flush
property of our watchers.
In the source code, our currentFlushPromise
is defined like this:
currentFlushPromise = Promise.resolve().then(flushJobs)
We use Promise.resolve
so that we can run flushJobs
in the next microtask. If you’re unfamiliar with microtasks, this means that instead of executing flushJobs
immediately, we’ll wait until we’ve reached the very end of our execution, and then run flushJobs
:
console.log('1')// Wait until we're done with everything elsePromise.resolve().then(() => console.log('3')console.log('2')
I highly recommend reading Jake Archibald’s fantastic post on tasks in Javascript to understand them better. It’s very much worth it if you’re a Javascript developer (which you are if you’re reading this).
This flushJobs
function will call of the jobs that have been queued, flushing them. And at the very end, we flush all of the post flush jobs as well.
To quickly recap what we’ve covered so far:
await nextTick()
is the same as await currentFlushPromise
currentFlushPromise
queues up the flushJobs
method in the next microtaskflushJobs
does the work of calling of our queued up post flush jobsNext, we need to figure out how post flush watchers are connected to all of this.
Somehow, it seems that a post flush watcher ends up as one of these post flush callbacks. They are certainly named very similarly. We just need to trace through the source code to make sure that this is true.
Looking at the source code, when we run a watcher with flush: "post"
it gets set up to queue the callback we pass in as a “post render effect” using queuePostRenderEffect
. If you dig around in the source code enough, you’ll discover that queuePostRenderEffect
is effectively just calling the function queuePostFlushCb
but with some extra stuff to handle Suspense.
So, ultimately, setting up a post flush watcher uses queuePostFlushCb
, which does exactly what it sounds like it does — queues up our post flush watcher as a post flush job.
And that’s the final piece of this puzzle!
This is the flow:
currentFlushPromise
queues up the flushJobs
method in the next microtaskflushJobs
does the work of calling of our queued up post flush jobsawait nextTick()
is the same as await currentFlushPromise
From this, we can see that our post flush watcher is executed inside of flushJobs
, which is inside of currentFlushPromise
. So, to rewrite nextTick
in a pseudocode:
async function nextTick() {await runPostFlushWatchers()}
When we write await nextTick()
, we’re also awaiting on all of the post flush watchers.
When we use a post flush watcher, it gets flushed at the end of the flushJobs
method. When we use nextTick
, however, it gets run in the very next microtask, right after flushJobs
.
This is because using nextTick
is the same as await currentFlushPromise
, and we know that currentFlushPromise
involves calling flushJobs
:
currentFlushPromise = Promise.resolve().then(flushJobs)
So we have this chain:
currentFlushPromise-> flushJobs-> post flush watchersnextTick-> our code after nextTick
The difference between these two implementations is only a single microtask. A post flush watcher will execute slightly earlier, but in practice this won’t make any real difference. Here’s our example from earlier:
const items = ref([])function addItem(newItem) {items.value.push(newItem)}// 1. Using a post flush watcherwatch(items, () => {// The DOM has been updated now// Do something with the updated list of items}, { flush: 'post' })// 2. Using a pre flush watcher with nextTickwatch(items, async () => {// The watcher gets called before the DOM updates// But we can wait by using nextTickawait nextTick()// The DOM has been updated now// Do something with the updated list of items})
However, if you have some really nasty bug that you’re dealing with, it might be helpful to know that you can force one set of watchers to always run last using nextTick
. In the above example, the second implementation will always run last, no matter what.
If you’re in this situation though, I wish you the best of luck. For the rest of us, these are functionally exactly the same.
The only meaningful difference is in code readability. Here, though, I don’t have any opinions (yet). In both cases, it’s clear that you’re waiting to do something until after the DOM updates. If you do have an opinion, I’m open to hearing them!
In the end, this was all just a long and complicated way of saying: it doesn’t really matter.
If you’re interested in learning more about advanced reactivity in Vue, check out my course Advanced Reactivity.
Instead of deep dives into the internals of Vue, the course has 22 exercises that will you master the advanced parts of Vue's reactivity system:
You can check out all the details here: Advanced Reactivity.