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:
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>
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.