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>

Playground link

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>

Playground link

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>

Playground Link

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.