Vue – Lifecycle Hooks and Template Refs

November 17, 20254 min readUpdated 8/24/2026

A component is created, rendered into the DOM, updated some number of times, and eventually removed. Lifecycle hooks let you run code at each of those moments.

The hooks

import { onMounted, onBeforeUnmount, onUpdated } from "vue";

onBeforeMount(() => {});    // after setup, before the first render
onMounted(() => {});        // the DOM exists -- this is the one you will use
onBeforeUpdate(() => {});   // data changed, DOM not yet patched
onUpdated(() => {});        // DOM patched
onBeforeUnmount(() => {});  // still attached -- clean up here
onUnmounted(() => {});      // gone

Register them at the top level of setup. They work by associating themselves with whichever component is currently being set up, so registering one inside a callback, a promise or an if either fails or attaches to the wrong component.

The code in <script setup> that is not in a hook is itself the "before created" moment — it runs first, every time, and is where you declare state.

onMounted

Use it for anything that needs the real DOM, or that should start once the component is on screen.

onMounted(loadPage);

That is the whole of the feed's mounted hook after its observer moved into a composable (lesson 15). Before that it also constructed an IntersectionObserver here — because new IntersectionObserver({ root: scroller.value }) needs scroller.value to be a real element, which it is not until mounted.

onMounted does not run on the server. Anything touching window, document or localStorage belongs here for that reason alone.

Fetching in onMounted, or not

You can start a request in onMounted, and plenty of code does. You can also just start it in the setup body — nothing about a fetch needs the DOM:

api.trendingTags().then((t) => (tags.value = t)).catch(() => {});

That fires as the component is created, one tick earlier. The demo application does both, choosing per case: the tag list is fire-and-forget, while the feed's first page is in onMounted because the observer must exist before slides can be registered.

onBeforeUnmount: the one that matters

Every hook that starts something needs a hook that stops it. Intervals, event listeners on window, observers, EventSource connections, and any subscription you opened will otherwise keep running after the component is gone — holding a reference to it, so the whole component tree stays in memory.

onBeforeUnmount(() => videoEl.value?.pause());

One line, and without it every reel you scroll past keeps playing audio.

The pattern for a listener:

function onResize() { width.value = window.innerWidth; }

onMounted(() => window.addEventListener("resize", onResize));
onBeforeUnmount(() => window.removeEventListener("resize", onResize));

Note the named function. removeEventListener matches by identity, so an inline arrow function in both places removes nothing at all — the listener stays, silently, forever. This is the single most common memory leak in component code.

Lesson 15 shows the better answer: put the pair inside a composable, so the caller cannot forget half of it.

onBeforeUnmount versus onUnmounted

onBeforeUnmount still has the DOM, so it is where you pause a video or read a final scroll position. onUnmounted is after removal, for anything that does not need the element. When in doubt use onBeforeUnmount.

Template refs

Sometimes you need the actual element — to call play(), to focus an input, to measure something. Declare a ref with the same name as the ref attribute:

<video ref="videoEl" ...></video>
const videoEl = ref(null);

// null until mounted, so every use is guarded.
function togglePlay() {
  const el = videoEl.value;
  if (!el) return;
  if (el.paused) el.play().catch(() => {});
  else el.pause();
}

The binding is populated just before onMounted and set back to null on unmount. Reading it in the setup body always gives null — that is not a bug, the element does not exist yet.

Refs inside v-for

A static ref="x" inside a loop cannot work — every iteration would claim the same name. Bind a function instead, and Vue calls it with each element:

      :ref="(el) => (slideEls[i] = el)"
const slideEls = ref([]);

which gives an array indexed the same way as the list, so slideEls.value[3] is the fourth slide's element.

A ref on a component

ref on a child component gives you the component instance rather than an element. With <script setup> a component is closed by default — the parent sees nothing — and the child opts in to exposing specific things:

// in the child
defineExpose({ focus, reset });

That default is deliberate and worth respecting. Reaching into a child to call its methods couples the two together in a way props and events do not. Use it for genuinely imperative things — focus, scroll, play — and nothing else.

nextTick

Changing reactive data does not update the DOM immediately. Vue batches changes and applies them on the next tick, which is why this reads the old value:

reels.value.push(...res.items);
console.log(slideEls.value.length);   // the OLD length -- not rendered yet

await nextTick() waits for the DOM to catch up:

    await nextTick();
    observeSlides();

The feed appends a page of reels and then has to observe the new slide elements — which cannot happen until they exist. Without the nextTick, observeSlides() would run over the previous page's elements and the pager would stop advancing at the end of page one.

That is the whole use of nextTick: I changed data, and now I need the DOM that resulted from it. Measuring a newly rendered element, focusing an input that just appeared, scrolling to a row that was just added.

Next: Composables — packaging all of this up so it can be reused.