javascripttypescriptvuejs3vue-reactivity

useTemplateRef is not reactive for arrays?


I'd like to react to changes made to an array of template ref elements created with a v-for loop over some collection of arbitrary objects. I'd like to pass this array to a component / composable and have it react to the changes.

The docs provide a way to save these elements to an array: https://vuejs.org/guide/essentials/template-refs.html#refs-inside-v-for

I've modified the provided example to see, if the resulting itemsRef array would be reactive. The code is:

<script setup>
import { ref, useTemplateRef, watch, markRaw } from 'vue'

const list = ref([1, 2, 3])

const itemRefs = useTemplateRef('items')

watch(itemRefs, () => {
  console.log("itemsRef:", itemRefs.value.length);
}, {deep: true});

watch(list, () => {
  console.log("list:", list.value.length, itemRefs.value.length);
}, { deep: true });
</script>

<template>
  <button @click="list.push(list.length + 1)">add</button>
  <ul>
    <li v-for="item in list" ref="items">
      {{ item }}
    </li>
  </ul>
</template>

But the return value of useTemplateRef seems to be a ShallowRef, judging from its TypeScript signature:

function useTemplateRef<T>(key: string): Readonly<ShallowRef<T | null>>

So the watch on itemRefs really runs just once:

shallow ref

As you can see, array's contents are changing (list is updated), but the changes to itemRefs are not captured by vue. I tried making the watch deep, but it does not help.

Is there a way to make the ref not shallow somehow? Or maybe there is another way?

const { createApp, ref, useTemplateRef, watch, markRaw } = Vue;

const app = createApp({
  setup() {
    const list = ref([1, 2, 3]);

    const itemRefs = useTemplateRef('items');

    watch(itemRefs, () => {
      console.log("itemsRef: ", itemRefs.value.length);
    }, { deep: true });

    watch(list, () => {
      console.log("list: ", list.value.length, itemRefs.value.length);
    }, { deep: true });
    
    return { list }
  }
});

app.mount('#app');
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<div id="app">
  <button @click="list.push(list.length + 1)">add</button>
  <ul>
    <li v-for="item in list" ref="items">
      {{ item }}
    </li>
  </ul>
</div>


Solution

  • Using const elements = ref([]) with ref="elements" inside a v-for does not guarantee order, as per vue's docs.

    Also, if your ref="elements" is a Component, the elements array will be full of ComponentPublicInstance elements.

    To battle this, I used this gist as the base and created this composable:

    export const useTemplateRefs = (elements: Ref<HTMLElement[]>) => {
      onBeforeUpdate(() => {
        elements.value = [];
      });
      const setElement = (elem: Element | ComponentPublicInstance | null, index: number): void => {
        if (elem == null) {
          return;
        }
        if (elem instanceof Element) {
          if (elem instanceof HTMLElement) {
            elements.value[index] = elem;
            return;
          }
        } else if (elem.$el instanceof HTMLElement) {
          elements.value[index] = elem.$el;
          return;
        }
        throw new Error("Unexpected element type");
      };
      return setElement;
    };
    

    Which can be used this way:

    <script setup lang="ts">
    const messages = ref([{id: 1, text: "Message"}]);
    const messageRefs = ref<HTMLElement[]>([]);
    const setRef = useTemplateRefs(messageRefs);
    </script>
    
    <template>
      <div class="container">
      <div
          v-for="(message, index) in messages"
          :key="message.id"
          :ref="el => setRef(el, index)"
        >
          {{ message.text }}
        </div>
      </div>
    </template>
    

    Now the messageRefs array is fully reactive and is in full sync with messages array.