A reatividade do Vue é baseada em "inscrições". Quando um efeito lê uma ref, o Vue registra que esse efeito depende desse valor. Quando o valor é atualizado, o Vue executa novamente esse efeito.

Quando você escreve um bloco <template> em um arquivo .vue, o processo de build transforma aquilo em uma função de renderização (semelhante ao que acontece com o JSX). Ou seja, se você utiliza uma ref no seu template, quando essa ref mudar, a sua função de template será executada novamente.

Outra forma de criar efeitos é com um watchEffect. Da mesma forma, se o callback do seu watchEffect ler alguma ref, e essa ref mudar, o callback será chamado novamente.

<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>

Link para o playground

Nesse exemplo, cada vez que increaseCount é chamado, alteramos o valor de count, o que faz a função <template> ser executada novamente, resultando na atualização do DOM. Como temos um watchEffect que lê count, também exibimos uma mensagem de log no console.

Mas o que acontece se modificarmos count mais de uma vez na mesma função?

function increaseCount() {
  count.value++;
  count.value++;
  count.value++;
  count.value++;
}

Felizmente, o Vue já possui uma otimização para isso, executando ambos os efeitos apenas uma vez, mesmo tendo modificado count 4 vezes. Experimente modificar a função no playground acima.

O Vue usa uma estratégia simples de "agendar" que esses efeitos precisam ser re-executados, e depois executa esses efeitos em lote. Além de melhorar a performance, evitando que um mesmo watchEffect execute várias vezes ou que o DOM seja modificado múltiplas vezes desnecessariamente, o Vue também consegue organizar a ordem em que esses efeitos são executados (efeitos em componentes pais devem acontecer antes de efeitos em componentes filhos).

Entretanto, o Vue fornece formas de "escapar" desse comportamento quando necessário.

Atuando depois de atualizar o DOM

Você provavelmente já encontrou o seguinte comportamento no Vue: você não consegue acessar um elemento DOM na mesma função que alterou a visibilidade dele.

<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">Mostrar input</button>
  <input id="input" v-if="showInput" />
</template>

Link para playground

Quando toggleInput modifica o valor de showInput, o Vue agenda a função de template para ser re-executada. Entretanto, quando tentamos selecionar o elemento, o input ainda não está no DOM.

Nesse caso, precisamos utilizar nextTick(), onde o "tick" é a execução das funções de template que vão atualizar o DOM. Você pode tanto passar um callback que será executado logo após, quanto aguardar a Promise que ele retorna.

function toggleInput() {
  showInput.value = !showInput.value;

  if (showInput.value) {
    nextTick(() => {
      const input = document.getElementById("input");
      input?.focus();
    });
  }
}

// OU

async function toggleInput() {
  showInput.value = !showInput.value;

  if (showInput.value) {
    await nextTick();
    const input = document.getElementById("input");
    input?.focus();
  }
}

Ordem dos efeitos

A ordem dessas execuções também é importante. Um watch ou watchEffect acontece sempre antes do DOM ser atualizado. Portanto, mesmo utilizando nextTick dentro de um watchEffect, você não tem acesso à versão atualizada do 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();
    // Não vai funcionar
    input?.focus();
  }
});
</script>

<template>
  <button @click="toggleInput">Mostrar input</button>
  <input v-if="showInput" id="input" />
</template>

Link do Playground

O Vue fornece duas formas de contornar esse problema. Você pode passar a opção flush: 'post' ou utilizar watchPostEffect, que é uma versão "pré-configurada" do watchEffect com essa opção já definida.

watchEffect(
  () => {
    if (showInput.value) {
      const input = document.getElementById("input");
      input?.focus();
    }
  },
  {
    flush: "post",
  }
);

// ou

watchPostEffect(() => {
  if (showInput.value) {
    const input = document.getElementById("input");
    input?.focus();
  }
});

Efeitos síncronos

Outra possibilidade que o Vue oferece é executar os watchers imediatamente, utilizando a opção flush: 'sync' ou watchSyncEffect. Voltando ao exemplo anterior:

watchEffect(
  async () => {
    console.log("count increased", count.value);
  },
  {
    flush: "sync",
  }
);

// ou

watchSyncEffect(async () => {
  console.log("count increased", count.value);
});

Agora teremos um log para cada vez que o estado for modificado. É claro que é preciso muito cuidado, efeitos síncronos podem causar problemas de performance e loops infinitos se não usados corretamente.

Conclusão

Exceto nos casos em que você precisa acessar ou modificar o DOM, raramente precisa se preocupar com a ordem de efeitos ou quando essas mudanças vão acontecer. O Vue possui muitas abstrações e otimizações, e é importante não tentar otimizar antes de realmente ter um problema.