Arrays and v-model in Vue 3
Vue’s template compiler deals with arrays with v-for
directive, with assisting :key
for better performance (and not only!). While everything is easy for basic displaying. We encourage the first problem when we try to use v-model
inside:
<div v-for="(item, idx) in items" :key="idx">
<input v-model="item" />
</div>
And here we go… While items
is of type Ref<string[]>
, the item
here is just string
, it won’t even compile, because v-model
tries to mount a handler for the event "update:modelValue"
, which tries to assign the new value to item
, which is basically a local variable in template renderer. If we use just :model-value="item"
, the template compiler will generate the code:
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(items.value, (item, idx) => {
return (_openBlock(), _createElementBlock("div", { key: idx }, [
_createElementVNode("input", { "model-value": item }, null, 8 /* PROPS */, _hoisted_1)
]))
}), 128 /* KEYED_FRAGMENT */))
As you see, item
here is literally a local variable, an argument of a function, to which you shouldn’t assign anything (it wouldn’t have any actual effect).
It’s not a problem if the array is an array of objects and we are mutating its fields, the objects here will keep the reactivity and Vue will allow mutating them without a problem. The problem will start, when using a different component there that will return a new object, instead of mutating the original one, for example, a picker for category, tags (that aren’t stored as just plain text).
Simple solution: use the array instance
Vue 3’s reactivity is awesome and works flawlessly with arrays, we can literally just mutate an item in an array, so we can go that way: instead of using the item
instance, we can use the index from the v-for
(idx
) and source array:
<div v-for="(item, idx) in items" :key="idx">
<input v-model="items[idx]" />
</div>
And now everything works as expected! The generated code looks like this:
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(items.value, (item, idx) => {
return (_openBlock(), _createElementBlock("div", { key: idx }, [
_withDirectives(_createElementVNode("input", {
"onUpdate:modelValue": $event => ((items.value[idx]) = $event)
}, null, 8 /* PROPS */, _hoisted_1), [
[_vModelText, items.value[idx]]
])
]))
}), 128 /* KEYED_FRAGMENT */))
We see that we use idx
item of items
array for both setting the model-value
prop and to set in "update:modelValue"
. The item
from the v-for
is not used at all.
This is a simple solution that will work in most simple cases.
What if the case isn’t simple?
The solution above assumes that we are working on an existing array, I mean… ref
. What if it would be a computed
? Maybe we want to filter a list first?
const items = ref(['foo', 'bar']);
const search = ref('');
const filteredList = computed(() => {
if (search.value.length == 0) return items.value;
return items.value.filter(item => item.includes(search.value));
});
We can’t just replace the items
with filteredList
in v-for
and keep the items[idx]
in the input
tag, because the given idx
won’t be the index of the item in the original array!
Again, if we mutate the items field, it wouldn’t be any issue here. The returned array will still contain reactive objects.
Sure, we could check if the element should be displayed in the template:
<div
v-for="(item, idx) in items"
:key="idx"
v-if="search.includes(item)"
>
<input v-model="items[idx]" />
</div>
But this solution is… just wrong, against Vue’s style guide. We need to remember that v-for
is above the v-if
directive, meaning that we can hide that way every item separately. But then, it will make the checks with every component rerender, even if nothing in the array changes!
Store the list of indices
One of the solutions is to provide an array of indexes instead:
const filteredIdx = computed(() => {
if (search.value.length == 0) {
return items.value.map((_, idx) => idx);
}
return items.value
.map((item, idx) => item.includes(search.value) ? idx : null)
.filter(i => i != null)
});
Then we can write the template:
<div v-for="idx in filteredIdx" :key="idx">
<input v-model="items[idx]" />
</div>
This solution also lets you display the items in a different order than the original array, before mapping the items to indices, sort the array (copying the array first by [...items.value].sort
or by using the new toSorted
method!).
Rather a clean solution, but if we would like to use the filtered array in a child component, we would have to provide both data: array and filtered list.
Wrap the array, but it’s not as simple as you think it is
Anyway, we need to keep the original index here, so… we better construct a type for a wrapped arrays… but how?
We could simply wrap it into an object:
type WrappedArray<T> = {
array: Ref<T[]>,
indices: Ref<number[]>;
};
and use it providing both as reactive values, but no, don’t do this. We better do a reactive wrapper for an array
Reactive array proxy
We could write a proxy object that still acts like a normal array, but can handle mutations on original array (or update it whole, by creating new array instance). We will have array of types:
type ProxyArrayItem<T> = { value: T, idx: number};
The value
field here will be reactive, we could do item.value = newItem
and it should work, replacing the item on the specific index in original array. We will allow to filter the items and sort them, so we will have the function header:
export function toArrayProxy<T>(opt: {
array: Ref<T[]>;
sort?: (a: T, b: T) => number;
filter?: (a: T) => boolean;
}): ComputedRef<ProxyArrayItem<T>[]> {
// return proxyArray
}
Next, we need an inner function that will return indices of items to show:
function indices(): number[] {
const { filter, sort } = opt;
let arr = opt.array.value.map((item, idx) => {
return { value: item, idx }
});
if (filter) arr = arr.filter((i) => filter(i.value));
if (sort) arr = arr.sort((a, b) => sort(a.value, b.value));
return arr.map(i => i.idx);
}
A hidden bonus here: the filter
and sort
functions can use reactive data and if indices
is used in a computed
, it will be recalculated when dependency will update.
And then, we need to create the array that will wrap it:
const proxyArray = computed(() => {
return indices().map(c => /* what? */);
}));
And yeah, what should we put there? Basically returning the { value: opt.array.value[c], idx: c }
won’t provide reactivity for value
. Here we have a few possible approaches. Computed’s value
returns exactly what it’s getter returns, so here we return a raw array of objects. We want the field value
(the array’s item’s) to point at the original array, with both getter and setter.
Just use another computed
inside!…?
Easiest approach:
const proxyArray = computed(() => {
return indices().map(idx => computed({
get: () => opt.array.value[idx],
set: (newValue) => opt.array.value[idx] = newValue,
}));
});
But then we won’t know, what is the real index in the original array, if we need it ever.
Also, we have another reactive element here… Well, n
reactive elements, each array item is separate reactive object.
Take a look at what we actually need here… Getter should return opt.array.value[idx]
, where idx
is returned from indices
. Setter should… depends on the approach:
- just set
opt.array.value[idx] = newValue
- call setter given in options, or assign to
opt.array.value = newArray
, wherenewArray
can be either done by any method of replacing an item in array:- copy array and replace item
- use
.splice(idx, 1, newValue)
- use new method
.with(idx, newValue)
We could easily provide just those setters and getters or… do it like it Vue does, but without using another level of reactivity! Here is where a simple object with a getter and setter comes in:
const proxyArray = computed(() => {
return indices().map(idx => ({
idx,
get value () {
return opt.array.value[idx];
},
set value(newValue) {
opt.array.value[idx] = newValue;
},
}));
});
So, basically, what happens here? Let see:
<div v-for="(item, i) in proxyArray" :key="i">
<ItemEditor v-model="item.value"/>
</div>
What happens here?
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_unref(proxy), (item, i) => {
return (_openBlock(), _createElementBlock("div", { key: i }, [
_createVNode(ItemEditor, {
modelValue: item.value,
"onUpdate:modelValue": ($event) => ((item.value) = $event)
}, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"])
]))
}), 128 /* KEYED_FRAGMENT */))
Let’s see, then modelValue: item.value
actually calls the getter, which returns opt.array.value[idx]
, which means we depend on given opt.array
(the component will rerender when we change the array) and on the array items opt.array.value[idx]
.
On the other side, onUpdate:modelValue
makes a simple assignation to .value
, which calls the setter, which does opt.array.value[idx] = newValue
. Everything we need. Of course, as I mentioned, we can change the way it’s assigned.
This setter will cause the update of the component it’s changing because the array items themselves were reactive before.
Logically, value
acts exactly like array’s item. Underneath it’s done by getter and setter and using a cached item, so getter doesn’t hit the array each time we try to get the item. If the item in array will change, there will be new instance of ArrayProxyItem
created anyway, caused by the dependency created while mapping items in function indices
. If T
is an object, it will remain reactive and will be the same instance as in the array.
Further development
We got it done easily, but why not make a step forward? We could easily make some common jobs easier with this solution, like removing items! Just add a method to returned items:
const proxyArray = computed(() => {
return indices().map(idx => ({
idx,
get value () {
return opt.array.value[idx];
},
set value(newValue) {
opt.array.value[idx] = newValue;
},
delete: () => opt.array.value.splice(idx, 1),
}));
});
And we can use it directly in the template:
<div v-for="(item, i) in proxyArray" :key="i">
<ItemEditor v-model="item.value" @remove="item.delete"/>
</div>
Further optimizations
JavaScript is a nice runtime environment, but sadly, it encourages developers to make the code slow… Sure, “no premature optimization”, but seriously… Don’t set up the traps in places you will regret later! If you are building something low-level, keep it fast and optimized, so you don’t have to care that much about optimization on high-level code (your components). Many web pages are using way too much resources, don’t join them!
I won’t talk about how the conventional for loop (for (const item of items)
) is much faster than the .forEach
. Look at the upper code… What happens here? While it looks clean, underneath the shiny shell of JavaScript it’s a hell… 3 closures!
Here comes the hated Object-Oriented Programming… Classes! Okay, it’s not so “object-oriented”, because we are going to use just one single class, but still.
We had previously declared the type ProxyArrayItem<T>
and… forgot about it. Now let’s change the type into an actual class:
class ArrayProxyItem<T> {
constructor(private array: Ref<T[]>, public idx: number) {}
get value() {
return this.array.value[this.idx];
}
set value(newValue) {
this.array.value[this.idx] = newValue;
}
delete() {
this.array.value.splice(this.idx, 1);
}
}
Not the we will have 3 class methods instead of 3 closures per every(!) item of array, while having still the same access interface.
The only difference is just creating the proxy array:
const proxyArray = computed(() => {
return indices().map(idx => new ArrayProxyItem(opt.array, idx));
});
A silly micro benchmark (without Vue’s part) that tests 1000 array elements says that this method is over 20 times faster when creating. But micro benchmarks are often just a curiosity that has little significance.
Sure, it might be lost in the all the work our JS app has to do, but… why waste time and memory, when it can be saved? Especially if it’s something that could be done without that layer of abstraction, we are doing it only to write code easier.
Results for different approaches:
- raw print: 17 MiB heap, 25ms render (and total)
- computed: 48 MiB heap, 10.1ms creation, 20.0ms render, 30.1ms total
- closures: 36 MiB heap, 13.7ms creation, 23,6ms render, 37.3ms total
- classes: 24 MiB heap, 7.8ms creation, 24ms render, 31.8ms total
Well, the worst is the approach using closures. Twice that long as using classes or computers. Computeds don’t provide the functionality we need, so it’s a different problem. There are no significant differences in rendering, the benchmark wasn’t done on clean system, so we need to apply a big measurement error.
For heap usage, those are peeks I found with setInterval
each 1ms. I’ve measured it multiple times, and results were always similar: with classes the peek never was as high as the others. Don’t trust those measures too much, those measures are not trustworthy, just the overall observation is that using classes had the lowest footprint memory. Computeds and closures were getting higher. I tried using profiler, but it also doesn’t find the actual peeks, and the observations were similar. I know, those tests were done with vite server, without devtools opened, but still the differences are noticable. Also note, that for raw print, there was used just one array, instead of 100.
Somehow rendering is fastest for computeds… It’s probably because it caches the item. Let’s do that in out class:
class ArrayProxyItem<T> {
_cache: T;
constructor(private array: Ref<T[]>, public idx: number) {
this._cache = array.value[idx];
}
get value() {
return this._cache;
}
set value(newValue) {
this.array.value[this.idx] = newValue;
}
delete() {
this.array.value.splice(this.idx, 1);
}
}
And we’ve managed to get down a few ms for objects for type, but it gets worse for primitives. It’s hard to find the best solution. The _cache
will still be reactive, if it ever change (the item instance, either object as whole or if it’s primitive) the proxyArray
computed should create a new proxy for that item anyway.
There might be a much bigger performance penalty when mutating data using nested computeds, but it’s a deeper problem, much harder to measure
Final code
export class ArrayProxyItem<T> {
_cache: T;
constructor(private array: Ref<T[]>, public idx: number) {
this._cache = array.value[idx];
}
get value() {
return this._cache;
}
set value(newValue) {
this.array.value[this.idx] = newValue;
}
delete() {
this.array.value.splice(this.idx, 1);
}
}
// export type ProxyArrayItem<T> = { value: T, idx: number};
export function toArrayProxy<T>(opt: {
array: Ref<T[]>;
sort?: (a: T, b: T) => number;
filter?: (a: T) => boolean;
}) {
function indices(): number[] {
const { filter, sort } = opt;
let arr = opt.array.value.map((item, idx) => {
return { value: item, idx };
});
if (filter) arr = arr.filter((i) => filter(i.value));
if (sort) arr = [...arr].sort((a, b) => sort(a.value, b.value));
return arr.map((i) => i.idx);
const proxyArray = computed(() => {
return indices().map((idx) => new ArrayProxyItem(opt.array, idx));
});
return proxyArray;
}
After using the cache, it works more like that:
The constructor copies the item (reference to it, if it's object) to local cache, so the original array doesn’t have to be used anymore, when we are reading the value. If you use ArrayProxyItem
, the dependency will be installed only on the T
object (if it’s reactive! A primitive won’t be reactive here, but it’s not any problem here), not on the array. If the item’s field change, everything will work fine on both ArrayProxyItem
's value and directly on the source array. If the item will change, there will be another array of ArrayProxyItem
created, so the cached reference isn’t any problem. Also again, if the new array will contain the same references, components won’t have to trigger updating the view.
I know it sounds complicated but… Thanks to the reactivity in Vue it just works.
Implementing different storing strategies
As I said, if we want to keep the vue/no-mutating-props
rule, we should take different approach to the setter, like reassigning whole array (pick one):
class ArrayProxyItem<T> {
set value(newValue) {
// newest approach, fast*
this.array.value = this.array.value.with(this.idx, newValue);
// older functional approach, slow
this.array.value = this.array.value.map((item, i) => i == idx ? newValue : item)
// copying, patching, aplying; ugly but fastest
const arr = [...this.array.value];
arr[this.idx] = newValue;
this.array.value = arr;
}
}
This will allow you to use a computed as an array, which setter emits update:modelValue
event. You can do the same with delete
method (pick one):
class ArrayProxyItem<T> {
delete() {
// filter the item out; nice, but slower
this.array.value = this.array.value.filter((_, idx) => this.idx != idx);
// copy, remove with splice, apply; looking ugly, but fast
const arr = [...this.array.value];
arr[this.idx].splice(this.idx, 1);
this.array.value = arr;
}
}
The main difference is that without overwriting the array’s instance, watch
won’t react on the change. Watching array is another wide problem. The proxyArray
computed will react on any change here, because it reads the items.
While I wouldn’t recommend implementing both in the ArrayProxyItem
, I would suggest implement both strategies with interfaces and use whatever version you need. Maybe add an option to toArrayProxy
to select the updating strategy?
Practical usage
The proxy implements two things: sorting and filtering. That means, having an array, you wrap it and display in different order, or hide some items:
const search = ref("");
const todos: ref<TodoItem[]>([]);
const todosList = toArrayProxy({
array: todos,
filter: (item) => {
// don't show subtasks here
if (item.parent == null) return false;
// if user typed something in search, filter the items
if (search.value.length == 0) return true;
return item.content.includes(search.value);
},
sort: (a, b) => {
return a.priority - b.priority;
}
});
That way we could use the new array to display, modify and remove items from the list.
If the list is returned from backend, it might be better to delete it by request and reload the whole list, but for optimization we could request the delete and just continue working without that item (this is the same thing we would achieve by reloading the array, but with reloading, we would lose the other unsaved changes, if there are any). If we wouldn’t sort, the items would remain in the same order, so we could use the index in original array for features like “add item directly after that one”.
We could still improve this by adding another functionalities, like listeners to change: triggers called when item is removed, moved etc.
Conclusion
We’ve managed to write a wrapper for an array, so we can use a completely reordered and filtered array in v-for
and still use v-model
to mutate the original array with that. If we are not printing huge amounts of data, the performance. The given solution still brings a lot of places to improve performance and usability.
Using the class approach results in fewer dependencies to track compared to using nested computed
s which creates another layer. Therefore, in the long term, this approach should be much more performant. Anyway, it makes many cases using arrays a lot easier, especially if we want to simply use v-model
on items directly.
Top comments (0)