Skip to content

Instantly share code, notes, and snippets.

@AlexVipond
Last active April 20, 2024 10:23
Show Gist options
  • Save AlexVipond/d0f82933f3451c9b1ed021a942817eb5 to your computer and use it in GitHub Desktop.
Save AlexVipond/d0f82933f3451c9b1ed021a942817eb5 to your computer and use it in GitHub Desktop.
Updating arrays of elements with function refs in Vue 3

Updating arrays of elements with function refs in Vue 3

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 is the behavior if you used elements.value.length = 0 in the onBeforeUpdate()

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
      }
    }
  }
)
@byronferguson
Copy link

That's an incredible through response. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment