Vue's reactivity is based on "subscriptions". When an effect reads a ref,
Vue registers that this effect depends on that value. When the value is updated,
Vue executes that effect again.
When you write a <template> block in a .vue file, the build process
transforms it into a render function (similar to what happens
with JSX). That is, if you use a ref in your template, when that ref changes,
your template function will be executed again.
Another way to create effects is with watchEffect. Similarly, if your watchEffect callback
reads some ref, and that ref changes, the callback will be called again.
<script setup>
import { ref, watchEffect } from "vue";
const count = ref(0);
function increaseCount() {
count.value++;
}
watchEffect(() => {
console.log("count increased", count.value);
});
</script>
<template>
<p>Counter is {{ count }}</p>
<button @click="increaseCount">Inc</button>
</template>
In this example, every time increaseCount is called, we change the value of count, which makes
the <template> function execute again, resulting in DOM updates.
Since we have a watchEffect that reads count, we also display a log message in the console.
But what happens if we mutate count more than once in the same function?
function increaseCount() {
count.value++;
count.value++;
count.value++;
count.value++;
}
Fortunately, Vue already has an optimization for this, executing both effects only once, even though we
mutated count 4 times. Try modifying the function in the playground above.
Vue uses a simple strategy of "scheduling" that these effects need to be re-executed,
and then executes these effects in batches. Besides improving performance, avoiding that the same watchEffect
executes multiple times or that the DOM is mutated multiple times unnecessarily, Vue also
manages to organize the order in which these effects are executed (effects in parent components should happen
before effects in child components).
However, Vue provides ways to "escape" this behavior when necessary.
Acting after DOM updates
You've probably encountered the following behavior in Vue: you can't access a DOM element in the same function that changed its visibility.
<script setup>
import { ref } from "vue";
const showInput = ref(false);
function toggleInput() {
showInput.value = !showInput.value;
if (showInput.value) {
const input = document.getElementById("input");
input?.focus();
}
}
</script>
<template>
<button @click="toggleInput">Show input</button>
<input id="input" v-if="showInput" />
</template>
When toggleInput mutates the value of showInput, Vue schedules the template function
to be re-executed. However, when we try to select the element,
the input is not yet in the DOM.
In this case, we need to use nextTick(), where the "tick" is the execution of template functions
that will update the DOM. You can either pass a callback that will be executed right after, or await
the Promise it returns.
function toggleInput() {
showInput.value = !showInput.value;
if (showInput.value) {
nextTick(() => {
const input = document.getElementById("input");
input?.focus();
});
}
}
// OR
async function toggleInput() {
showInput.value = !showInput.value;
if (showInput.value) {
await nextTick();
const input = document.getElementById("input");
input?.focus();
}
}
Effect order
The order of these executions is also important. A watch or watchEffect always happens before
the DOM is updated. Therefore, even using nextTick inside a watchEffect,
you don't have access to the updated version of the DOM.
<script setup>
import { nextTick } from "vue";
import { ref, watchEffect } from "vue";
const showInput = ref(false);
async function toggleInput() {
showInput.value = !showInput.value;
}
watchEffect(async () => {
if (showInput.value) {
const input = document.getElementById("input");
await nextTick();
// This won't work
input?.focus();
}
});
</script>
<template>
<button @click="toggleInput">Toggle Input</button>
<input v-if="showInput" id="input" />
</template>
Vue provides two ways to work around this problem. You can pass the flush: 'post' option or use watchPostEffect, which is a "pre-configured" version of watchEffect with this option already set.
watchEffect(
() => {
if (showInput.value) {
const input = document.getElementById("input");
input?.focus();
}
},
{
flush: "post",
}
);
// or
watchPostEffect(() => {
if (showInput.value) {
const input = document.getElementById("input");
input?.focus();
}
});
Synchronous effects
Another possibility that Vue offers is to execute watchers immediately, using the flush: 'sync' option or watchSyncEffect.
Going back to the previous example:
watchEffect(
async () => {
console.log("count increased", count.value);
},
{
flush: "sync",
}
);
// or
watchSyncEffect(async () => {
console.log("count increased", count.value);
});
Now we'll have a log for each time the state is mutated. Of course, great care is needed, synchronous effects can cause performance issues and infinite loops if not used correctly.
Conclusion
Except in cases where you need to access or mutate the DOM, you rarely need to worry about the order of effects or when these changes will happen. Vue has many abstractions and optimizations, and it's important not to try to optimize before you actually have a problem.