Vue.js course Β· Module 9: Composables & VueUse

Performance and Composable Optimization

9 min read
In this lesson5

At the NOVA LAB station every watt of energy counts - and in Vue applications every unnecessary render and computation is wasted performance. The problem usually isn't visible right away: a panel with ten sensors runs smoothly, but with ten thousand readings it starts to stutter, and a forgotten timer eats memory after an hour of work. Let's learn composable optimization techniques and find out which of them really help.

shallowRef - Shallow Reactivity

ref() creates deep reactivity - Vue wraps every nested object and tracks changes to every field. shallowRef() reacts only to a change of the whole value, not its inner properties, so it saves work with large data. The difference is easiest to see on an array:

1import { shallowRef, triggerRef } from 'vue'
2
3// Large data list - shallowRef is more efficient
4const sensorReadings = shallowRef([])
5
6// This will NOT trigger an update (shallow reactivity)
7sensorReadings.value.push({ id: 1, temp: 22 })
8
9// This will trigger an update - assigning a new array
10sensorReadings.value = [...sensorReadings.value, { id: 1, temp: 22 }]
11
12// Alternatively: manually trigger an update
13sensorReadings.value.push({ id: 2, temp: 23 })
14triggerRef(sensorReadings) // Force update

After push the array really does contain a new element, but Vue doesn't know about it and the screen doesn't refresh. A new array assigned to .value or a call to triggerRef tells Vue about the change. A composable for large datasets therefore follows one rule: every operation creates a new array:

1<script setup>
2import { shallowRef, triggerRef, computed } from 'vue'
3
4// Efficient composable for large datasets
5function useLargeDataset() {
6  const items = shallowRef([])
7  const count = computed(() => items.value.length)
8
9  function addItem(item) {
10    // Create a new array - triggers reactivity
11    items.value = [...items.value, item]
12  }
13
14  function addMany(newItems) {
15    // One assignment and one array copy instead of N
16    items.value = [...items.value, ...newItems]
17  }
18
19  function removeItem(id) {
20    items.value = items.value.filter(item => item.id !== id)
21  }
22
23  function updateItem(id, updates) {
24    items.value = items.value.map(item =>
25      item.id === id ? { ...item, ...updates } : item
26    )
27  }
28
29  return { items, count, addItem, addMany, removeItem, updateItem }
30}
31</script>

filter and map return new arrays by nature, so they fit shallowRef perfectly. addMany adds the whole batch with a single assignment - calling addItem in a loop would copy the entire, growing array on every iteration.

When to use shallowRef:

  • Large arrays (hundreds or thousands of elements)
  • Objects with deep nesting
  • Data you change rarely or as a whole

Computed Caching

Computed properties are cached automatically - they are recalculated only when their dependency changes. This is a key optimization. In the composable below we compare computed with a plain function called in the template:

1import { ref, computed } from 'vue'
2
3function useFilteredExperiments() {
4  const experiments = ref([])
5  const searchQuery = ref('')
6  const category = ref('all')
7
8  // GOOD: computed is cached
9  // Recalculates ONLY when experiments, searchQuery or category changes
10  const filtered = computed(() => {
11    console.log('Recalculating filtered experiments') // logs rarely
12    let result = experiments.value
13
14    if (searchQuery.value) {
15      const q = searchQuery.value.toLowerCase()
16      result = result.filter(e =>
17        e.name.toLowerCase().includes(q)
18      )
19    }
20
21    if (category.value !== 'all') {
22      result = result.filter(e => e.category === category.value)
23    }
24
25    return result
26  })
27
28  // BAD: function recalculates on every render
29  function getFiltered() {
30    console.log('Recalculating every time!') // logs frequently
31    return experiments.value.filter(e =>
32      e.name.toLowerCase().includes(searchQuery.value.toLowerCase())
33    )
34  }
35
36  // GOOD: chaining computed in a pipeline
37  const stats = computed(() => ({
38    total: filtered.value.length,
39    active: filtered.value.filter(e => e.status === 'active').length,
40    completed: filtered.value.filter(e => e.status === 'completed').length
41  }))
42
43  return { experiments, searchQuery, category, filtered, stats }
44}

If the template reads filtered five times in one render, the filter runs once, and on the next render with no change in the data - not at all. getFiltered() in the template recalculates on every call. stats uses the ready-made filtered, so the chain doesn't repeat the filtering.

watchEffect vs watch - Performance

watch and watchEffect differ not only in API, but also in what they track. watchEffect registers as dependencies the reactive values it actually read during its last run, while watch tracks only the source given as its first argument:

1import { ref, watch, watchEffect } from 'vue'
2
3const temperature = ref(22)
4const pressure = ref(101)
5const humidity = ref(45)
6
7// watchEffect - tracks what it read during its last run
8// temperature: always; pressure: only when temperature > 30;
9// humidity: never, because the effect does not read it
10watchEffect(() => {
11  console.log('watchEffect:', temperature.value)
12  // pressure becomes a dependency only when
13  // the condition is met and this line runs
14  if (temperature.value > 30) {
15    console.log('Pressure:', pressure.value)
16  }
17})
18
19// watch - tracks ONLY the specified source
20// Fires ONLY on temperature change
21watch(temperature, (newTemp) => {
22  console.log('watch:', newTemp)
23  // You can read other refs without creating dependencies
24  if (newTemp > 30) {
25    console.log('Pressure:', pressure.value)
26  }
27})

In practice it looks like this: at 22Β°C a pressure change doesn't trigger watchEffect, a humidity change never triggers it, and once the temperature rises to 35Β°C the effect starts reacting to pressure too. watch fires only on temperature changes the whole time.

Rule: use watch when you need to react to specific changes. Use watchEffect when you want automatic tracking of multiple dependencies.

Memory Leaks in Composables

One of the most common problems - uncleaned resources cause memory leaks. A leak happens in four steps: the composable starts a setInterval in onMounted, the component is unmounted, the timer keeps ticking and holds a reference to the state, and the memory cannot be freed. Compare both versions:

1import { ref, onMounted, onUnmounted, watch } from 'vue'
2
3// BAD - memory leak!
4function useLeakyTimer() {
5  const seconds = ref(0)
6
7  onMounted(() => {
8    setInterval(() => { // Never cleaned up!
9      seconds.value++
10    }, 1000)
11  })
12
13  return { seconds }
14}
15
16// GOOD - proper cleanup
17function useSafeTimer() {
18  const seconds = ref(0)
19  const isRunning = ref(false)
20  let intervalId = null
21
22  function start() {
23    if (isRunning.value) return
24    isRunning.value = true
25    intervalId = setInterval(() => {
26      seconds.value++
27    }, 1000)
28  }
29
30  function stop() {
31    if (!isRunning.value) return
32    isRunning.value = false
33    clearInterval(intervalId)
34    intervalId = null
35  }
36
37  // Always clean up in onUnmounted
38  onUnmounted(() => {
39    if (intervalId) {
40      clearInterval(intervalId)
41    }
42  })
43
44  return { seconds, isRunning, start, stop }
45}

Every mount of the "BAD" version adds another immortal timer. The same rule applies to network requests: a response that arrives after the component unmounts has nowhere to go. The browser's AbortController lets you cancel a fetch - you pass its signal to the request, and abort() cancels it in onUnmounted:

1import { ref, onMounted, onUnmounted } from 'vue'
2
3function useSensorFeed(url) {
4  const data = ref(null)
5  const loading = ref(false)
6  const error = ref(null)
7  let controller = null
8
9  async function fetchData() {
10    controller = new AbortController()
11    loading.value = true
12    error.value = null
13    try {
14      const response = await fetch(url, { signal: controller.signal })
15      data.value = await response.json()
16    } catch (e) {
17      if (e.name !== 'AbortError') error.value = e.message
18    } finally {
19      loading.value = false
20    }
21  }
22
23  onMounted(() => fetchData())
24  onUnmounted(() => controller?.abort())
25
26  return { data, loading, error, fetchData }
27}

An aborted fetch ends with an error named AbortError, which we deliberately skip, because it is not a failure but our own decision. The order of steps is always the same: refs, the fetching function, the call in onMounted, the abort in onUnmounted and returning the API.

Composable Performance Checklist

The table below gathers the techniques from this lesson:

TechniqueWhen to use
shallowRefLarge arrays/objects, infrequent updates
computed instead of functionsValue depends on reactive state
watch instead of watchEffectSpecific source to observe
Cleanup in onUnmountedTimers, event listeners, WebSocket, fetch
Batch updatesVue merges changes from one tick itself; with shallowRef replace the whole value once
triggerRefMutating shallowRef without creating a copy

The batch updates row needs an explanation, because a popular myth surrounds it. Vue buffers DOM updates until the next tick, so the component renders once, no matter how many fields you change in one function:

1// Batch updates - Vue merges changes from one tick anyway
2import { ref } from 'vue'
3
4function useBatchUpdate() {
5  const data = ref({ temp: 0, pressure: 0, humidity: 0 })
6
7  // Three mutations in one tick - still 1 render
8  function updateFields(temp, pressure, humidity) {
9    data.value.temp = temp
10    data.value.pressure = pressure
11    data.value.humidity = humidity
12  }
13
14  // Replacing the whole object - also 1 render
15  // (the only way if data were a shallowRef)
16  function replaceData(temp, pressure, humidity) {
17    data.value = { temp, pressure, humidity }
18  }
19
20  return { data, updateFields, replaceData }
21}

Both functions cause a single render, which is easy to check with a render counter. A difference appears only with a watcher using the flush: 'sync' option, which fires three times for updateFields, and with shallowRef, where a change to a single field would not be noticed at all.

My advice: don't optimize in advance. Start with a plain ref and computed, and switch to shallowRef when a measurement shows a problem with large data. Never postpone the cleanup in onUnmounted, though - that is not an optimization, it is a duty. In the editor below you will compare ref and shallowRef on update counters, see the computed cache at work and run a timer with and without a leak.

Remember: an efficient station doesn't recalculate what it already knows, and always turns off the lights in a module it leaves.

Code for this lesson: App.vue
1<script setup>
2import { ref, shallowRef, computed, watch, triggerRef, onUnmounted } from 'vue'
3
4// ===== PERFORMANCE DEMO =====
5
6// 1. shallowRef vs ref comparison
7const regularItems = ref([])
8const shallowItems = shallowRef([])
9const renderCounts = ref({ regular: 0, shallow: 0 })
10
11// Track renders
12watch(regularItems, () => { renderCounts.value.regular++ }, { deep: true })
13watch(shallowItems, () => { renderCounts.value.shallow++ })
14
15function addToRegular() {
16  regularItems.value.push({ id: Date.now(), temp: Math.random() * 50 })
17  // Each push triggers deep watch
18}
19
20function addToShallow() {
21  // Must create new array for shallowRef to react
22  shallowItems.value = [...shallowItems.value, { id: Date.now(), temp: Math.random() * 50 }]
23}
24
25function addBatchToShallow(count) {
26  const newItems = Array.from({ length: count }, (_, i) => ({
27    id: Date.now() + i, temp: Math.random() * 50
28  }))
29  // One update for all items
30  shallowItems.value = [...shallowItems.value, ...newItems]
31}
32
33// 2. Computed caching demo
34const experiments = ref([
35  { name: 'Soil Analysis', category: 'geology', status: 'active' },
36  { name: 'Atmosphere Test', category: 'atmosphere', status: 'completed' },
37  { name: 'Water Search', category: 'geology', status: 'active' },
38  { name: 'Radiation Scan', category: 'safety', status: 'active' },
39  { name: 'Plant Growth', category: 'biology', status: 'completed' }
40])
41const searchQuery = ref('')
42const computedCallCount = ref(0)
43
44// Computed: cached, recalculates only when dependency changes
45const filteredExperiments = computed(() => {
46  computedCallCount.value++ // Count calls
47  if (!searchQuery.value) return experiments.value
48  return experiments.value.filter(e =>
49    e.name.toLowerCase().includes(searchQuery.value.toLowerCase())
50  )
51})
52
53// Access filteredExperiments multiple times - computed is cached
54const filteredCount = computed(() => filteredExperiments.value.length)
55const activeFiltered = computed(() =>
56  filteredExperiments.value.filter(e => e.status === 'active')
57)
58
59// 3. Memory leak demo
60const timerSeconds = ref(0)
61let leakyInterval = null
62const leakyIntervals = ref(0)
63
64function createLeakyTimer() {
65  // BAD: no cleanup reference saved properly
66  leakyInterval = setInterval(() => { timerSeconds.value++ }, 1000)
67  leakyIntervals.value++
68}
69
70function createSafeTimer() {
71  if (leakyInterval) clearInterval(leakyInterval)
72  leakyInterval = setInterval(() => { timerSeconds.value++ }, 1000)
73}
74
75function stopTimer() {
76  if (leakyInterval) {
77    clearInterval(leakyInterval)
78    leakyInterval = null
79  }
80  timerSeconds.value = 0
81}
82
83onUnmounted(() => {
84  if (leakyInterval) clearInterval(leakyInterval)
85})
86</script>
87
88<template>
89  <div class="perf-lab">
90    <h1>Performance Optimization - NOVA LAB</h1>
91
92    <section>
93      <h2>shallowRef vs ref</h2>
94      <div class="compare">
95        <div class="col">
96          <h3>ref (deep)</h3>
97          <p>Items: {{ regularItems.length }} | Renders: {{ renderCounts.regular }}</p>
98          <button @click="addToRegular">Add Item</button>
99          <button @click="regularItems = []">Clear</button>
100        </div>
101        <div class="col">
102          <h3>shallowRef (shallow)</h3>
103          <p>Items: {{ shallowItems.length }} | Renders: {{ renderCounts.shallow }}</p>
104          <button @click="addToShallow">Add Item</button>
105          <button @click="addBatchToShallow(10)">Add 10 (batch)</button>
106          <button @click="shallowItems = []">Clear</button>
107        </div>
108      </div>
109    </section>
110
111    <section>
112      <h2>Computed Caching</h2>
113      <input v-model="searchQuery" placeholder="Search experiments..." class="search" />
114      <p class="stats">
115        Computed calls: {{ computedCallCount }} |
116        Filtered: {{ filteredCount }} |
117        Active: {{ activeFiltered.length }}
118      </p>
119      <div v-for="e in filteredExperiments" :key="e.name" class="exp-item" :class="e.status">
120        {{ e.name }} <span class="cat">[{{ e.category }}]</span>
121        <span class="status-tag">{{ e.status }}</span>
122      </div>
123    </section>
124
125    <section>
126      <h2>Memory Leak Prevention</h2>
127      <p>Timer: {{ timerSeconds }}s | Active intervals: {{ leakyIntervals }}</p>
128      <div class="btns">
129        <button @click="createLeakyTimer" class="bad">Leaky Timer (BAD)</button>
130        <button @click="createSafeTimer" class="good">Safe Timer (GOOD)</button>
131        <button @click="stopTimer">Stop All</button>
132      </div>
133      <p class="hint">Try clicking "Leaky Timer" multiple times - each creates a NEW interval without clearing the old one!</p>
134    </section>
135  </div>
136</template>
137
138<style scoped>
139.perf-lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
140h1 { color: #00ff88; text-align: center; }
141h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; margin-top: 2rem; }
142h3 { color: #00ff88; margin: 0 0 0.5rem; }
143section { margin: 1.5rem 0; }
144.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
145.col { background: rgba(0,180,216,0.1); border: 1px solid #00b4d8; border-radius: 8px; padding: 1rem; }
146button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
147button:hover { background: #00ff88; color: #0a0e27; }
148button.bad { background: #ff0055; }
149button.bad:hover { background: #ff4444; color: #fff; }
150button.good { background: #00ff88; color: #0a0e27; }
151.search { width: 100%; background: #1a1e3f; border: 1px solid #00b4d8; color: #fff; padding: 0.6rem; border-radius: 4px; margin: 0.5rem 0; font-family: inherit; }
152.stats { color: #00b4d8; font-size: 0.9rem; }
153.exp-item { padding: 0.5rem; margin: 0.3rem 0; background: rgba(0,0,0,0.3); border-radius: 4px; display: flex; align-items: center; gap: 0.5rem; }
154.exp-item.active { border-left: 3px solid #00ff88; }
155.exp-item.completed { border-left: 3px solid #888; opacity: 0.7; }
156.cat { color: #888; font-size: 0.8rem; }
157.status-tag { margin-left: auto; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 10px; background: rgba(0,255,136,0.2); color: #00ff88; }
158.exp-item.completed .status-tag { background: rgba(136,136,136,0.2); color: #888; }
159.btns { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
160.hint { color: #ffb400; font-size: 0.8rem; font-style: italic; margin-top: 0.5rem; }
161</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. When do we use shallowRef() instead of ref()?

  2. 2. What is the main advantage of computed() over a regular function?

These are 2 of 5 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Create a shallowRef for a sensor list. Add addMany() that creates a new array instead of push. Compare with ref.

  • Vertical ordering

    Arrange the steps of how a memory leak happens in useLeakyTimer:

  • Click in order

    Arrange a correct batch update for shallowRef:

  • Code editor

    Use computed() for filtered and stats instead of functions. Show that computed does not recalculate without a dependency change.

  • Horizontal ordering

    Arrange the correct removal of an element from shallowRef:

Useful articles