skip to content

In Vue 3, how should v-for render a filtered or sorted view of a reactive array, and why does sort() in a computed backfire?

level: middleimportance: should knowfreq 66%

answer

  1. derive, do not mutate
  2. a computed returns the view
  3. sort and reverse mutate in place
  4. copy before sorting

basics

~20 s

Vue 3 renders a derived view by iterating a computed that returns a new filtered or sorted array; sort() and reverse() mutate the source in place, so a computed must copy first, as in [...items].sort().

solid answer

~40 s

Keep the source array untouched and iterate a `computed` that **returns a new array**: `const active = computed(() => users.value.filter(u => u.active))`, then `v-for="u in active" :key="u.id"`. The computed is cached and re-evaluates only when what it read changes. `filter`, `map`, `slice` and `concat` already return new arrays, but `sort()` and `reverse()` **mutate in place**, so inside a getter they rewrite the source, which is a side effect in a computed and reorders every other view of that data. Copy first: `[...users.value].sort(...)`. Inside a nested `v-for`, where a computed per item is awkward, a method called in the template works. When you do want to change the data, Vue detects mutation methods such as `push`, `splice` and `sort`, and assigning a new array (`items.value = items.value.filter(...)`) is also fine and reuses matching DOM.

code

vue · 24 lines
vue
<script setup lang="ts">
import { computed, ref } from 'vue'

interface Task { id: number; title: string; done: boolean }
const tasks = ref<Task[]>([])
const showDone = ref(false)

const visibleTasks = computed(() =>
  [...tasks.value]
    .filter(t => showDone.value || !t.done)
    .sort((a, b) => a.title.localeCompare(b.title))
)

function clearDone() {
  tasks.value = tasks.value.filter(t => !t.done)
}
</script>

<template>
  <button @click="clearDone">Clear done</button>
  <ul>
    <li v-for="task in visibleTasks" :key="task.id">{{ task.title }}</li>
  </ul>
</template>

go deeper

for a junior

Use a computed that returns the filtered or sorted list and iterate it with a key, leaving the original array unchanged.

for a middle

Explain which array methods mutate, why sort and reverse inside a getter corrupt the source, and when a method replaces a computed.

for a senior

Spot a mutating getter causing a list elsewhere to reorder, and choose mutation or replacement to match the app's data flow.

for a principal

Set conventions for derived views, such as pure getters, copy-before-sort and heavy derivation in one place, so shared data stays predictable.

## The goal: a view, not a new source of truth A list often needs to be shown **filtered** (only active users) or **sorted** (by name) without changing the underlying data, which other parts of the app still read in its original form. Vue's answer is a **computed property**: a cached, derived value that recomputes only when the reactive data it read changes. ```ts const users = ref<User[]>([]) const query = ref('') const visibleUsers = computed(() => { const q = query.value.toLowerCase() return users.value .filter(u => u.name.toLowerCase().includes(q)) .sort((a, b) => a.name.localeCompare(b.name)) }) ``` The template then iterates the view: `<li v-for="u in visibleUsers" :key="u.id">`. Because `filter` already returned a fresh array, calling `sort` on **that** array is safe. ## Why sort() and reverse() backfire Array methods split into two groups: | Mutating (change the array in place) | Non-mutating (return a new array) | |---|---| | `push`, `pop`, `shift`, `unshift` | `filter`, `map`, `slice` | | `splice`, `sort`, `reverse` | `concat`, `toSorted`, `toReversed` | Writing `computed(() => users.value.sort(byName))` sorts the **source** array itself: 1. The getter now has a side effect, which Vue's guide explicitly warns against for computed getters. 2. Every other reader of `users`, such as a table in original order or a save call, silently gets the sorted order. 3. Because the source is reactive, the in-place sort is itself a tracked write, so every component and effect reading `users` updates as if someone had re-sorted the data on purpose. The fix is a copy before the mutating call: `[...users.value].sort(byName)`, or a non-mutating equivalent such as `toSorted` where the target browsers support it. ## Filtering inside nested loops A computed has no arguments, so it cannot easily filter **each** inner list of a nested `v-for`. The guide's alternative is a method called from the template: ```vue-html <ul v-for="group in groups" :key="group.id"> <li v-for="n in even(group.numbers)" :key="n">{{ n }}</li> </ul> ``` A method is not cached: it runs on every render of the component. That is fine for small lists; for large ones, precompute a derived structure in one computed instead, for example `groups` mapped to `{ ...group, evens: [...] }`. ## When you do want to change the data Changing the source is a separate decision from viewing it. Vue tracks both styles on a reactive array: - **Mutation**: `items.value.push(x)`, `splice`, `sort` and the other mutating methods trigger updates. - **Replacement**: `items.value = items.value.filter(i => !i.done)` swaps in a new array. This does **not** throw away the whole rendered list; with keys, Vue matches the objects that are still present and reuses their DOM. Which to choose is mostly a matter of data flow. Replacement fits immutable update styles and store actions; mutation is concise inside the component that owns the array. ## Cost and caching A computed view is evaluated lazily and cached: when the component re-renders for an unrelated reason, such as a hover flag toggling, the filtered array is reused as is. An inline expression like `v-for="u in users.filter(isActive)"` runs the filter on **every** render and returns a new array each time, which also means any child receiving that array as a prop sees a new reference. For a search box over a few thousand rows, the difference shows up as typing lag. Two more details keep the view cheap: - Put the cheapest, most selective step first: filter before sort, so the sort handles fewer items. - Derive the lowercase search string once outside the callback rather than per item. ## Checklist - Iterate a **computed** view, not a filtered-in-template expression repeated in several places. - **Copy before `sort` or `reverse`** inside any getter. - Keep the `:key` on the underlying item id, so a filtered or re-sorted view still maps rows to the same instances. - Use a **method** only where a computed cannot take the argument, and move heavy work into one computed. - Prefer a computed filter over putting `v-if` on the same element as `v-for`, which the guide discourages for filtering.

  • Does replacing a reactive array in Vue 3 with a filtered copy re-create every list element?
    No. Assigning `items.value = items.value.filter(...)` gives Vue a new array, but when it re-renders it matches old and new vnodes, by key if present, and reuses the DOM for items still in the list. Only removed items are unmounted.
  • When would you use a method instead of a computed to filter a Vue 3 `v-for` list?
    When the filter needs an argument per iteration, as in a nested `v-for` filtering each inner list. A method runs on every render because it is not cached, so for large data it is better to build one computed that precomputes the derived structure.

saying these in an interview costs you the question

  • Calling sort() directly on the source inside a computed is fine.
  • Replacing an array makes Vue rebuild every list element.
  • A method and a computed are cached the same way.
  • Filtering with v-if on each v-for row is the recommended approach.
  • filter() mutates the array it is called on.