skip to Main Content

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>

2

Answers


  1. Chosen as BEST ANSWER

    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.


  2. itemRefs is updated (e.g reactive). But it’s always updated 1 render cycle after list has changed.

    Wrap your console.log in nextTick (imported from ‘vue’) and the numbers will match:

    const { createApp, ref, useTemplateRef, watch, nextTick } = 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,
          () => {
            nextTick(() =>
              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>

    Also note you can’t watch it deeply, since it’s a shallow ref. To be able to get this functionality, use a normal ref:

    const { createApp, ref, watch, nextTick } = Vue
    
    const app = createApp({
      setup() {
        const list = ref([1, 2, 3])
        const items = ref([])
        watch(
          items,
          () => {
            console.log('itemsRef: ', items.value.length)
          },
          { deep: true }
        )
        watch(
          list,
          () => {
            nextTick(() =>
              console.log('list: ', list.value.length, items.value.length)
            )
          },
          { deep: true }
        )
        return { list, items }
      }
    })
    
    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>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search