An older Vue 3 options component calls this.$el.querySelector('input') in mounted and broke after its template gained a second root element; why, and what should replace it?
answer
- multiple roots mean a fragment
- placeholder text node
- template ref instead
- $refs filled after mount
basics
~20 sIn Vue 3, a component with several root nodes has no single root element, so this.$el is a placeholder text node that has no querySelector. Put ref="input" on the element and read this.$refs.input after mount.
solid answer
~50 sVue 3 allows multiple root nodes. For a single-root component `this.$el` is that element, but with several roots it is the **placeholder node** Vue uses to track the component's position — an empty text node in the browser — so `this.$el.querySelector` is `undefined` and the call throws. The fix is to stop depending on the root: add `ref="input"` to the element and use `this.$refs.input`, which Vue fills after the component is mounted. A few rules come with `$refs`: it is `undefined` for that element during the first render, so do not read it in the template; an element behind `v-if` exists only after the DOM update, so wait with `await this.$nextTick()` after toggling it; a `ref` inside `v-for` yields an array whose order is not guaranteed to match the source; and `$refs` is a plain object, not reactive state, so a computed cannot depend on it.
code
vue · 27 lines<script>
export default {
data() {
return { query: '', editing: false }
},
mounted() {
// works with one root or several
this.$refs.search.focus()
},
methods: {
async startEditing() {
this.editing = true
await this.$nextTick() // the v-if element exists only after the DOM update
this.$refs.title.select()
}
}
}
</script>
<template>
<form @submit.prevent>
<input ref="search" v-model="query" />
<input v-if="editing" ref="title" />
<button type="button" @click="startEditing">Rename</button>
</form>
<p class="hint">Press Enter to search</p>
</template>go deeper
Recall that $el and $refs are empty until the component is mounted, and that a ref attribute is the way to reach one element.
Explain what $el is for single-root, text-root and multi-root templates, and the $refs rules: after mount, arrays in v-for, not reactive.
Diagnose a regression caused by a template gaining a root, audit the component's other $el assumptions, and replace them with template refs.
Set a codebase rule that DOM access goes through named template refs rather than $el, so template edits cannot silently break imperative code.
## The scenario An inherited Options API component focuses its input on mount: ```js mounted() { this.$el.querySelector('input').focus() } ``` It worked for years. Then someone added a hint paragraph beside the form in the template, making it: ```html <form>…<input v-model="query" /></form> <p class="hint">Press Enter to search</p> ``` Now mounting throws `this.$el.querySelector is not a function`. ## What $el is in Vue 3 `$el` is the root DOM node the instance manages, and it is `undefined` until the component is mounted. Its value depends on the template's root: | Template root | `this.$el` | |---|---| | One element | That element | | Text only | The text node | | **Several root nodes** | The **placeholder node** Vue uses to track the component's position — a text node, or a comment node during SSR hydration | Vue 3 supports **multiple root nodes** (fragments), which is exactly what the edit created. The component no longer has one root element to return, so `$el` becomes an empty text node. Text nodes have no `querySelector`, hence the error. The Vue docs recommend template refs over `$el` for this reason: `$el` changes meaning whenever the template's root shape does. ## Why older code assumes an element Multi-root components are a Vue 3 addition: in Vue 2, a component template needed exactly one root, so many templates were wrapped in a single `<div>` and code written in that era could safely treat `this.$el` as an element. Components ported to Vue 3 keep working only while their template keeps one root. Removing a now-unnecessary wrapper `<div>`, or adding a sibling such as a hint paragraph, quietly changes what `$el` means. Because the template still renders correctly, the breakage appears only in the imperative code that touched `$el`, often far from the edit. ## The fix: template refs 1. Mark the element: `<input ref="input" v-model="query" />`. 2. Read it after mount: `this.$refs.input.focus()` in `mounted`. `this.$refs` is an object of DOM elements and child component instances registered with the `ref` attribute. It no longer cares how many roots the template has. ## The rules that come with $refs - **Filled after mount.** The element does not exist until the first render finishes, so `$refs.input` is `undefined` in `created`, and reading `$refs` in a template expression gives `undefined` on the first render. - **Conditional elements.** If the input sits behind `v-if="editing"`, setting `this.editing = true` does not create it synchronously; DOM updates are batched. Wait with `await this.$nextTick()`, then read `this.$refs.input`. `$nextTick` is the instance-bound version of `nextTick`: a callback passed to it gets the instance as `this`. - **Inside `v-for`.** A `ref` on a repeated element gives an **array** of elements, and the docs warn that its order is **not guaranteed** to match the source array — look items up by a data attribute or an index you control rather than by position. - **Not reactive.** `$refs` is a plain object that Vue fills in during rendering. Nothing tracks reads from it, so a `computed` or `watch` based on `this.$refs` will not re-run when refs change. Treat it as an escape hatch for imperative DOM work — focus, measure, scroll — not as state. - **A child component ref** gives the child's instance, so `this.$refs.picker.open()` calls a child method; a child using `<script setup>` exposes only what it passes to `defineExpose`. ## Auditing the rest of the component While you are in the file, look for other `$el` assumptions that break on the same edit: - `this.$el.classList`, `this.$el.getBoundingClientRect()` or `this.$el.addEventListener` — each assumes an element root; - a parent reading `this.$refs.child.$el` to measure a child component whose template may gain a sibling root; - attribute fallthrough: with several roots, Vue cannot pick which root gets a parent's `class` and listeners automatically, so it warns until one root binds `$attrs` explicitly. A short checklist for any inherited options component: 1. Replace every `$el` DOM query with a named template ref. 2. Move any `$refs` read out of `created`, templates and `computed`. 3. Add `await this.$nextTick()` before reading a ref whose element was just toggled on. 4. Stop indexing a `v-for` ref array by position. Replacing each with an explicit template ref makes the component independent of its template's root shape, which is the lasting fix rather than wrapping the template back in a single `<div>`.
- Why would wrapping the template back in a single div be a weaker fix than switching to a template ref?It restores `$el` as an element, but the component still depends on its root shape, so the next edit that adds a sibling root breaks it again. The extra wrapper also changes the DOM and styling. A template ref names the exact element and keeps working whatever the root becomes.
- A computed property in a Vue 3 options component returns this.$refs.list?.children.length and never updates. Why?`$refs` is a plain object that Vue fills while rendering; it is not reactive, so the computed records no dependency on it and has no reason to re-evaluate. Compute from the reactive data that drives the list instead, such as `this.items.length`, and use refs only for imperative DOM work in hooks or methods.
saying these in an interview costs you the question
- this.$el is always the component's first root element.
- this.$refs is available in created because refs are declared in the template.
- A ref inside v-for returns an array in the same order as the source.
- A computed based on this.$refs updates when the element changes.
- The only fix is to wrap the template in a single root div again.