DEV Community

jkonieczny
jkonieczny

Posted on • Updated on

[Vue] Arrays and v-model on it's items with a simple wrapper

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>
Enter fullscreen mode Exit fullscreen mode

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 */))
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 */))
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode

Then we can write the template:

<div v-for="idx in filteredIdx" :key="idx">
  <input v-model="items[idx]" />
</div>
Enter fullscreen mode Exit fullscreen mode

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[]>;
};
Enter fullscreen mode Exit fullscreen mode

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};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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? */);
}));
Enter fullscreen mode Exit fullscreen mode

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,
  }));
});
Enter fullscreen mode Exit fullscreen mode

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, where newArray 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;
    },
  }));
});
Enter fullscreen mode Exit fullscreen mode

So, basically, what happens here? Let see:

<div v-for="(item, i) in proxyArray" :key="i">
    <ItemEditor v-model="item.value"/>
</div>
Enter fullscreen mode Exit fullscreen mode

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 */))
Enter fullscreen mode Exit fullscreen mode

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.

How it works

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),
  }));
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

After using the cache, it works more like that:

How it works with local cache reference

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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 computeds 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)