Vue.js course Β· Module 2: Template Syntax

Template Refs

6 min read
In this lesson5

Sometimes you need direct access to a DOM element - like an engineer who needs to check a physical sensor.

Templates describe how the panel should look, and Vue takes care of the page elements itself. Some tasks, however, cannot be written declaratively: placing the cursor in the command field right after the panel opens, measuring a container, or handing a <canvas> element to an external charting library. Then you need a handle to the real element - and that is what template refs give you. In the second part of the lesson you will also meet a few special directives for unusual jobs.

Basic Template Refs

The special ref attribute marks an element in the template. In the script you declare a ref with the same name and the value null, and once the component is mounted, Vue puts the element into it. The moment of mounting is caught by the onMounted function - a lifecycle hook that Vue calls when the component is already on the page; we will cover the lifecycle in module 6.

1<template>
2  <div class="input-panel" style="background: #0a0e27; padding: 1rem;">
3    <input ref="inputRef" type="text" placeholder="Module code"
4           style="background: #0a0e27; border: 1px solid #00b4d8; color: #00ff88; padding: 0.5rem;">
5    <button @click="focusInput" class="btn-focus">Set focus</button>
6  </div>
7</template>
8
9<script setup>
10import { ref, onMounted } from 'vue'
11
12// Name must match the ref attribute in template
13const inputRef = ref(null)
14
15function focusInput() {
16  inputRef.value?.focus()
17}
18
19onMounted(() => {
20  // Element is available after mounting
21  inputRef.value?.focus()
22})
23</script>

The order is always the same: declare ref(null), put the ref="inputRef" attribute on the element, mount, and only then use it. Before mounting, and also after the element is removed by v-if, the ref holds null, which is why ?. protects you from an error. It is the same ref() function you use for data - here the container simply holds an element instead of a number.

Since Vue 3.5 the recommended way is the useTemplateRef helper. Its argument must match the value of the ref attribute, and the variable can have any name:

1<script setup>
2import { useTemplateRef, onMounted } from 'vue'
3
4const commandInput = useTemplateRef('command-input')
5
6onMounted(() => {
7  commandInput.value?.focus()
8})
9</script>
10
11<template>
12  <input ref="command-input" placeholder="Module code">
13</template>

It works identically, and in TypeScript the element type is inferred automatically. The older form still works, and you will meet it in existing code and in the exercises, but in new projects I recommend useTemplateRef.

Refs in v-for

When ref sits on an element with v-for, after mounting you get an array of elements rather than a single element:

1<template>
2  <div class="modules-list">
3    <ul style="list-style: none; background: #0a0e27;">
4      <li v-for="module in modules" :key="module.id" ref="itemRefs"
5          style="color: #00b4d8; padding: 0.5rem; border-left: 2px solid #00ff88;">
6        {{ module.name }}
7      </li>
8    </ul>
9  </div>
10</template>
11
12<script setup>
13import { ref, onMounted } from 'vue'
14
15const modules = ref([
16  { id: 1, name: 'Oxygen Generator' },
17  { id: 2, name: 'Water Recycler' }
18])
19
20const itemRefs = ref([])
21
22onMounted(() => {
23  console.log(itemRefs.value) // Array of LI elements
24})
25</script>

The console shows two li elements. The docs warn, however, that the order of this array does not have to match the order of the data, so do not assume that the element at index 0 is the first module.

Function Refs

Instead of a name you can pass a function by using the dynamic :ref binding. Vue calls it with the element on every component update, and with null when the element is unmounted:

1<template>
2  <input :ref="setInputRef" type="text"
3         style="background: #0a0e27; border: 1px solid #00ff88; color: #00b4d8;">
4</template>
5
6<script setup>
7import { ref } from 'vue'
8
9const dynamicRef = ref(null)
10
11function setInputRef(el) {
12  dynamicRef.value = el
13  console.log('Element set:', el)
14}
15</script>

You decide where to store the element, for example in a map keyed by an identifier. Just remember the null case.

Refs on Components

A ref attribute on a component gives you access to its instance. SensorPanel.vue is a separate component file, imported like any other module - we will deal with components in module 6. The parent calls the child's method after mounting:

1<template>
2  <SensorPanel ref="sensorRef" />
3</template>
4
5<script setup>
6import { ref, onMounted } from 'vue'
7import SensorPanel from './SensorPanel.vue'
8
9const sensorRef = ref(null)
10
11onMounted(() => {
12  // Access component methods and properties
13  sensorRef.value?.refreshData()
14})
15</script>

Components using <script setup> are private by default: the parent sees nothing until the child exposes it with the defineExpose macro, which does not need to be imported:

1<!-- SensorPanel.vue - must expose methods -->
2<script setup>
3import { ref } from 'vue'
4
5const sensorData = ref({})
6
7function refreshData() {
8  console.log('Refreshing sensor data!')
9}
10
11// defineExpose - make available to the parent
12defineExpose({
13  refreshData,
14  sensorData
15})
16</script>

defineExpose has to be called before the first await. This kind of connection couples components tightly, so use it sparingly - props and events are usually the better choice.

Special Directives

A few built-in directives solve rarer tasks, but they are worth knowing. v-text sets the element's textContent, so it replaces the element's entire content, while mustaches replace only their own fragment:

1<template>
2  <p v-text="statusMessage"></p>
3  <p>Status: {{ statusMessage }}</p>
4</template>

Both paragraphs show the message, but only the second one keeps the Status label. v-once renders an element once and then treats it as static. v-memo (Vue 3.2+) memoizes a fragment of the template and updates it only when one of the values in its array changes:

1<template>
2  <p v-once>Mission code: {{ missionCode }}</p>
3
4  <div v-for="module in modules" :key="module.id" v-memo="[module.id === selectedId]">
5    {{ module.name }} - selected: {{ module.id === selectedId }}
6  </div>
7</template>

A later change of missionCode will not refresh the paragraph. After selectedId changes, only the two modules whose flag changed are re-rendered. v-memo="[]" works like v-once, and badly chosen dependencies leave stale data on screen, which is why it is a tool for very long lists. The last two directives concern compilation:

1<template>
2  <code v-pre>{{ this will not be compiled }}</code>
3</template>
4
5<style>
6[v-cloak] {
7  display: none;
8}
9</style>

v-pre skips compilation of the element and its children, so you see the mustaches literally. v-cloak is needed only without a build step, when Vue compiles a template written directly in HTML: together with this CSS rule it hides the raw {{ }} until the app is mounted. In a Vite project you do not need it.

Treat template refs as a last resort - first check whether the template is enough. In the module project you will combine all these tools into one hologram panel.

Remember: a template ref is a service key to a single device - you reach for it only after mounting, and only when the control panel is not enough.

Code for this lesson: App.vue
1<script setup>
2import { ref, onMounted } from 'vue'
3
4// Template refs
5const inputRef = ref(null)
6const containerRef = ref(null)
7const canvasRef = ref(null)
8
9// Data
10const focusCount = ref(0)
11const containerInfo = ref({})
12const canvasDrawn = ref(false)
13
14// Reactive data for v-once demonstration
15const staticData = ref({
16  launchDate: '2087-03-15',
17  missionCode: 'NOVA-MARS-001',
18  initialCrew: 6
19})
20
21const dynamicCounter = ref(0)
22
23// Functions
24function focusInput() {
25  if (inputRef.value) {
26    inputRef.value.focus()
27    focusCount.value++
28  }
29}
30
31function getContainerDimensions() {
32  if (containerRef.value) {
33    const rect = containerRef.value.getBoundingClientRect()
34    containerInfo.value = {
35      width: Math.round(rect.width),
36      height: Math.round(rect.height),
37      top: Math.round(rect.top),
38      left: Math.round(rect.left)
39    }
40  }
41}
42
43function drawOnCanvas() {
44  if (canvasRef.value) {
45    const ctx = canvasRef.value.getContext('2d')
46    const canvas = canvasRef.value
47
48    // Clear canvas
49    ctx.fillStyle = '#0a0e27'
50    ctx.fillRect(0, 0, canvas.width, canvas.height)
51
52    // Draw Mars
53    ctx.beginPath()
54    ctx.arc(200, 150, 80, 0, Math.PI * 2)
55    ctx.fillStyle = '#ff6600'
56    ctx.fill()
57
58    // Draw craters
59    ctx.beginPath()
60    ctx.arc(180, 130, 20, 0, Math.PI * 2)
61    ctx.fillStyle = '#cc5500'
62    ctx.fill()
63
64    ctx.beginPath()
65    ctx.arc(220, 160, 15, 0, Math.PI * 2)
66    ctx.fill()
67
68    // Draw NOVA LAB base
69    ctx.fillStyle = '#00ff88'
70    ctx.fillRect(150, 220, 100, 40)
71
72    // Draw dome
73    ctx.beginPath()
74    ctx.arc(200, 220, 50, Math.PI, 0)
75    ctx.strokeStyle = '#00b4d8'
76    ctx.lineWidth = 3
77    ctx.stroke()
78
79    // Add text
80    ctx.fillStyle = '#00ff88'
81    ctx.font = 'bold 16px Courier'
82    ctx.fillText('NOVA LAB', 160, 245)
83
84    canvasDrawn.value = true
85  }
86}
87
88function incrementCounter() {
89  dynamicCounter.value++
90}
91
92onMounted(() => {
93  getContainerDimensions()
94
95  // Auto-focus on mount
96  setTimeout(() => {
97    focusInput()
98  }, 500)
99})
100</script>
101
102<template>
103  <div class="advanced-lab">
104    <h1>Advanced Directives - NOVA LAB</h1>
105
106    <!-- Template Refs Section -->
107    <section class="lab-section">
108      <h2>Template Refs - Accessing DOM Elements</h2>
109
110      <!-- Input ref example -->
111      <div class="demo-card">
112        <h3>ref on an input - Programmatic focus</h3>
113        <div class="ref-demo">
114          <input
115            ref="inputRef"
116            type="text"
117            placeholder="This input can be focused programmatically..."
118            class="input-field"
119          >
120          <button @click="focusInput" class="btn btn-primary">
121            Focus Input
122          </button>
123        </div>
124        <p class="info-text">Number of focus calls: {{ focusCount }}</p>
125        <code class="code-hint">ref="inputRef" β†’ inputRef.value.focus()</code>
126      </div>
127
128      <!-- Container ref example -->
129      <div class="demo-card">
130        <h3>ref on container - Reading dimensions</h3>
131        <div ref="containerRef" class="ref-container">
132          <p>This container has ref="containerRef"</p>
133          <p>Click button to read its dimensions</p>
134        </div>
135        <button @click="getContainerDimensions" class="btn btn-success">
136          Get Dimensions
137        </button>
138        <div v-if="containerInfo.width" class="dimensions-info">
139          <p>Width: {{ containerInfo.width }}px</p>
140          <p>Height: {{ containerInfo.height }}px</p>
141          <p>Position: ({{ containerInfo.left }}px, {{ containerInfo.top }}px)</p>
142        </div>
143      </div>
144
145      <!-- Canvas ref example -->
146      <div class="demo-card">
147        <h3>ref on a canvas - Drawing</h3>
148        <canvas
149          ref="canvasRef"
150          width="400"
151          height="300"
152          class="canvas-field"
153        ></canvas>
154        <button @click="drawOnCanvas" class="btn btn-warning">
155          {{ canvasDrawn ? 'Redraw' : 'Draw' }} Mars Base
156        </button>
157        <code class="code-hint">ref="canvasRef" β†’ canvasRef.value.getContext('2d')</code>
158      </div>
159    </section>
160
161    <!-- v-once Section -->
162    <section class="lab-section">
163      <h2>v-once - One-time rendering</h2>
164
165      <div class="demo-card">
166        <h3>Static data (v-once)</h3>
167        <div v-once class="static-content">
168          <p><strong>Launch Date:</strong> {{ staticData.launchDate }}</p>
169          <p><strong>Mission Code:</strong> {{ staticData.missionCode }}</p>
170          <p><strong>Initial Crew:</strong> {{ staticData.initialCrew }}</p>
171          <p class="info-text">This data is rendered only once and will not update</p>
172        </div>
173      </div>
174
175      <div class="demo-card">
176        <h3>Comparison: v-once vs normal binding</h3>
177        <div class="comparison-grid">
178          <div class="comparison-item">
179            <h4>With v-once (does not update)</h4>
180            <div v-once class="counter-display static">
181              {{ dynamicCounter }}
182            </div>
183          </div>
184          <div class="comparison-item">
185            <h4>Without v-once (updates)</h4>
186            <div class="counter-display dynamic">
187              {{ dynamicCounter }}
188            </div>
189          </div>
190        </div>
191        <button @click="incrementCounter" class="btn btn-primary">
192          Increment Counter ({{ dynamicCounter }})
193        </button>
194        <p class="info-text">
195          Left counter stays at the initial value, right one updates
196        </p>
197      </div>
198    </section>
199
200    <!-- v-cloak Section -->
201    <section class="lab-section">
202      <h2>v-cloak - Hiding unrendered templates</h2>
203
204      <div class="demo-card">
205        <h3>v-cloak - Prevents showing <code v-pre>{{ }}</code></h3>
206        <div v-cloak class="cloak-demo">
207          <p>This section uses v-cloak</p>
208          <p>With slow loading, the user won't see raw mustaches <code v-pre>{{ }}</code></p>
209          <p>Data: {{ staticData.missionCode }}</p>
210        </div>
211        <code class="code-hint">
212          v-cloak + CSS: [v-cloak] { display: none; }
213        </code>
214      </div>
215    </section>
216
217    <!-- Info Box -->
218    <div class="info-box">
219      <h3>Advanced Vue directives</h3>
220      <div class="info-grid">
221        <div>
222          <h4>Template Refs:</h4>
223          <ul>
224            <li><strong>ref="name"</strong> - Creates a reference to an element</li>
225            <li><strong>const name = ref(null)</strong> - Declaration in setup</li>
226            <li><strong>name.value</strong> - Access to DOM element</li>
227            <li>Available after onMounted()</li>
228          </ul>
229        </div>
230        <div>
231          <h4>v-once:</h4>
232          <ul>
233            <li>Renders the element/component only once</li>
234            <li>Skips all subsequent updates</li>
235            <li>Optimization for static content</li>
236            <li>Useful for large data that does not change</li>
237          </ul>
238        </div>
239        <div>
240          <h4>v-cloak:</h4>
241          <ul>
242            <li>Hides unrendered templates</li>
243            <li>Requires CSS: [v-cloak] { display: none; }</li>
244            <li>Prevents "flashing" <code v-pre>{{ }}</code></li>
245            <li>Removed automatically after compilation</li>
246          </ul>
247        </div>
248        <div>
249          <h4>Uses of ref:</h4>
250          <ul>
251            <li>Programmatic focus on an input</li>
252            <li>Integration with DOM libraries</li>
253            <li>Canvas and SVG manipulation</li>
254            <li>Scrolling to an element</li>
255            <li>Reading element dimensions</li>
256          </ul>
257        </div>
258      </div>
259    </div>
260  </div>
261</template>
262
263<style scoped>
264/* v-cloak CSS */
265[v-cloak] {
266  display: none;
267}
268
269.advanced-lab {
270  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
271  min-height: 100vh;
272  padding: 2rem;
273  color: #ffffff;
274  font-family: 'Courier New', monospace;
275}
276
277h1 {
278  text-align: center;
279  color: #00ff88;
280  text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
281  margin-bottom: 3rem;
282}
283
284h2 {
285  color: #00b4d8;
286  margin-bottom: 1.5rem;
287  border-bottom: 2px solid #00b4d8;
288  padding-bottom: 0.5rem;
289}
290
291h3 {
292  color: #00ff88;
293  font-size: 1.1rem;
294  margin-bottom: 1rem;
295}
296
297h4 {
298  color: #00b4d8;
299  font-size: 1rem;
300  margin-bottom: 0.75rem;
301}
302
303.lab-section {
304  background: rgba(0, 255, 136, 0.05);
305  border: 2px solid #00ff88;
306  border-radius: 10px;
307  padding: 2rem;
308  margin-bottom: 2rem;
309}
310
311.demo-card {
312  background: rgba(0, 180, 216, 0.1);
313  border: 2px solid #00b4d8;
314  border-radius: 8px;
315  padding: 1.5rem;
316  margin-bottom: 1.5rem;
317}
318
319.ref-demo {
320  display: flex;
321  gap: 1rem;
322  margin-bottom: 1rem;
323  flex-wrap: wrap;
324}
325
326.input-field {
327  background: rgba(0, 0, 0, 0.3);
328  border: 2px solid #00b4d8;
329  border-radius: 5px;
330  padding: 0.75rem;
331  color: #ffffff;
332  font-family: 'Courier New', monospace;
333  font-size: 1rem;
334  flex: 1;
335  min-width: 250px;
336}
337
338.input-field:focus {
339  outline: none;
340  border-color: #00ff88;
341  box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
342  animation: focusPulse 0.5s ease;
343}
344
345@keyframes focusPulse {
346  0%, 100% { transform: scale(1); }
347  50% { transform: scale(1.02); }
348}
349
350.ref-container {
351  background: rgba(0, 255, 136, 0.1);
352  border: 2px dashed #00ff88;
353  border-radius: 8px;
354  padding: 2rem;
355  text-align: center;
356  margin: 1rem 0;
357}
358
359.dimensions-info {
360  background: rgba(0, 0, 0, 0.3);
361  border-left: 4px solid #00ff88;
362  border-radius: 5px;
363  padding: 1rem;
364  margin-top: 1rem;
365}
366
367.dimensions-info p {
368  margin: 0.5rem 0;
369  color: #00ff88;
370}
371
372.canvas-field {
373  display: block;
374  width: 100%;
375  max-width: 400px;
376  background: #0a0e27;
377  border: 2px solid #00b4d8;
378  border-radius: 8px;
379  margin: 1rem 0;
380}
381
382.static-content {
383  background: rgba(0, 255, 136, 0.1);
384  border: 2px solid #00ff88;
385  border-radius: 8px;
386  padding: 1.5rem;
387}
388
389.static-content p {
390  margin: 0.75rem 0;
391  color: #ffffff;
392}
393
394.static-content strong {
395  color: #00b4d8;
396}
397
398.comparison-grid {
399  display: grid;
400  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
401  gap: 1.5rem;
402  margin: 1.5rem 0;
403}
404
405.comparison-item {
406  background: rgba(0, 0, 0, 0.3);
407  border-radius: 8px;
408  padding: 1.5rem;
409  text-align: center;
410}
411
412.counter-display {
413  font-size: 3rem;
414  font-weight: bold;
415  padding: 2rem;
416  border-radius: 8px;
417  margin: 1rem 0;
418}
419
420.counter-display.static {
421  background: rgba(255, 180, 0, 0.2);
422  border: 2px solid #ffb400;
423  color: #ffb400;
424}
425
426.counter-display.dynamic {
427  background: rgba(0, 255, 136, 0.2);
428  border: 2px solid #00ff88;
429  color: #00ff88;
430  animation: pulse 2s infinite;
431}
432
433.cloak-demo {
434  background: rgba(0, 180, 216, 0.1);
435  border: 2px solid #00b4d8;
436  border-radius: 8px;
437  padding: 1.5rem;
438}
439
440.btn {
441  padding: 0.75rem 1.5rem;
442  border: none;
443  border-radius: 5px;
444  font-size: 1rem;
445  font-weight: bold;
446  cursor: pointer;
447  transition: all 0.3s ease;
448  font-family: 'Courier New', monospace;
449  margin: 0.5rem;
450}
451
452.btn-primary {
453  background: #00ff88;
454  color: #0a0e27;
455}
456
457.btn-success {
458  background: #00b4d8;
459  color: #ffffff;
460}
461
462.btn-warning {
463  background: #ffb400;
464  color: #0a0e27;
465}
466
467.btn:hover {
468  transform: scale(1.05);
469  box-shadow: 0 5px 15px rgba(0, 255, 136, 0.4);
470}
471
472.code-hint {
473  display: block;
474  background: rgba(0, 0, 0, 0.5);
475  border-left: 4px solid #00ff88;
476  padding: 0.75rem;
477  border-radius: 5px;
478  color: #00ff88;
479  font-family: 'Courier New', monospace;
480  font-size: 0.9rem;
481  margin-top: 1rem;
482}
483
484.info-text {
485  color: #00b4d8;
486  font-size: 0.9rem;
487  margin-top: 1rem;
488}
489
490.info-box {
491  background: rgba(0, 255, 136, 0.05);
492  border: 2px solid #00ff88;
493  border-radius: 10px;
494  padding: 2rem;
495}
496
497.info-box h3 {
498  margin-top: 0;
499  margin-bottom: 1.5rem;
500}
501
502.info-grid {
503  display: grid;
504  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
505  gap: 2rem;
506}
507
508.info-box ul {
509  list-style: none;
510  padding: 0;
511}
512
513.info-box li {
514  padding: 0.5rem 0;
515  color: #ffffff;
516  border-left: 3px solid #00b4d8;
517  padding-left: 1rem;
518  margin: 0.5rem 0;
519}
520
521.info-box strong {
522  color: #00ff88;
523}
524
525@keyframes pulse {
526  0%, 100% { opacity: 1; }
527  50% { opacity: 0.7; }
528}
529</style>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which Vue mechanism gives direct access to a DOM element from the component's template?

  2. 2. What is the difference between {{ }} and v-text?

  3. 3. What does v-pre do?

  4. 4. What does the v-once directive do in Vue.js?

  5. 5. missionCode changes from 'A-1' to 'B-2'. What does <p v-once>Code: {{ missionCode }}</p> show then?

  6. 6. Why would you use v-cloak?

  7. 7. Why should you be careful when using v-html?

  8. 8. Which Vue directive is used for performance optimization through memoization?

  9. 9. Which key modifier in Vue.js is used to detect the Escape key?

  10. 10. What should you do when a template becomes too complex?

  11. 11. What does v-memo do in Vue 3?

  12. 12. Which binding calls onSelf only when the element itself is clicked, not its child?

  13. 13. Which practice is recommended in Vue templates?

Hands-on tasks in the game

  • Code editor

    Use a template ref to automatically focus an input after the component mounts.

  • Vertical ordering

    Order the steps of using a template ref:

  • Vertical ordering

    Arrange the template ref code pieces in the order they take effect:

  • Click in order

    Arrange the template ref code with auto-focus:

  • Vertical ordering

    Arrange the style binding methods from simple to advanced:

  • Vertical ordering

    Order the steps of v-if in Vue:

  • Horizontal ordering

    Arrange the correct v-for syntax with index access:

Useful articles