I did a screencast series recently talking about my favorite Vue 3 feature: function refs. Check out the screencast series for more context-in this gist, I'm going to answer a fantastic question I got in one of the videos' comment section.
This question was about how to properly update arrays of elements that are captured by function refs. Let's check out the basic principle first, before we look at the details of the question.
<script setup>
import { ref, onBeforeUpdate } from 'vue'
// To get references to the elements rendered by `v-for`, we
// first set up an array:
const elements = ref([])
// Then, we write a function ref, which will capture each element
// and store it in the correct order in the array.
const functionRef = (el, index) => {
// We specifically assign to the `index` property here. We can't assign
// a new value to the array, otherwise every single element will trigger a new
// component update cycle, and we'll have an endless rendering loop.
//
// In theory, we could use `.push(el)` here, but in my experience,
// `push` doesn't preserve the exact order of elements as well as
// setting the precise index.
elements.value[index] = el
}
// Before every component update (e.g. updates triggered by changes
// to any other reactive reference in the component), we have to empty
// out the array, to make sure we get rid of any stale references to
// elements that might not be on the page anymore, or might have
// changed order during re-rendering.
onBeforeUpdate(() => {
elements.value = []
})
</script>
<template>
<ul>
<!-- We bind our function ref to the `ref` attribute
of the `v-for` element -->
<li
v-for="(item, index) in [0, 1, 2]"
:ref="el => functionRef(el, index)"
>
{{ item }}
</li>
</ul>
</template>
The important snippet of that code is the onBeforeUpdate
hook, which is what the Vue docs recommends you write.
onBeforeUpdate(() => {
elements.value = []
})
This code does two notable things:
- It empties out the array of elements, which the update cycle will then refill when the
v-for
list re-renders. - It's an assignment to a reactive reference, so it will trigger any watchers that are watching the
elements
reference.
The question I got in the comments was:
What happens if, instead of running elements.value = []
inside onBeforeUpdate
, we run elements.value.length = 0
?
Mutating the length
property of an array does successfully empty out the array, and it would get rid of any stale element references in our case.
However, mutating length
does not trigger reactive updates in Vue.
I made an SFC playground to prove it.
In the playground, there are two arrays: one is "normal", meaning that we clear it out by assigning a new, empty array. The other is "mutated length", meaning that we clear it out by mutating its length
property.
As you can see, the watcher that watches the normal array fires on every update, while the watcher that watches the mutated length array never fires.
If you're confident that the list you're rendering with v-for
will have the exact same order and exact same length on every render, this is not a problem. Event listeners and attribute bindings won't go stale. It's safe to reset the array on every update by setting its length
to 0
. But in fact, mutating the length
is not even necessary in that case—you can just bind your function ref, and forget about the onBeforeUpdate
hook entirely.
// If you're confident that the order and length of `elements`
// won't change, skip the `onBeforeUpdate` hook entirely.
//
// This code is all you would need:
const elements = ref([])
const functionRef = (el, index) => {
elements.value[index] = el
}
But if there's any chance the items in your list will change order, or if items will be added or removed, I recommend assigning the empty array instead. Then, you can set up watchers that will detect the element array updates, and inside those watchers, you can check if the array has changed length or order. If so, you'll need to remove stale event listeners and add fresh ones, bind fresh values to attributes, etc.
Here's some sample code for checking whether or not elements' have changed order, or whether elements have been added or removed:
import { ref, onBeforeUpdate, watch } from 'vue'
const elements = ref([])
const elementsRef = (el, index) => elements.value[index] = el
onBeforeUpdate(() => elements.value = [])
// This watcher will run on every update
watch(
elements,
(currentElements, previousElements) => {
if (currentElements.length !== previousElements.length) {
// The number of elements has changed. Stale side effects
// need be cleaned up, and fresh side effects need to run.
return
}
// We've established that the number of elements is the same,
// but it's still possible that the elements could have changed
// order.
for (let i = 0; i < currentElements.length; i++) {
if (currentElements[i].isSameNode(previousElements[i])) {
// The elements have changed order. Stale side effects
// need be cleaned up, and fresh side effects need to run.
return
}
}
}
)
That's an incredible through response. Thank you.