Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 076 / 100

List rendering with v-for: practical tips and trade-offs

Render lists correctly.

Expona Admin7 min read

List rendering with v-for: practical tips and trade-offs

List rendering is one of the most common patterns in Vue applications, yet it's also where many teams introduce subtle bugs that only surface under load or after rapid user interactions. Getting list rendering with v-for: practical tips and trade-offs right means understanding not just the syntax, but the performance and correctness implications of each choice you make. In this article, we'll walk through the patterns we've refined across production e-commerce, SaaS, and ride-hailing platforms.

Always use a stable :key

The :key binding is Vue's way of tracking list item identity across re-renders. When you provide a key, Vue can match old instances to new ones and reuse DOM elements efficiently. Without it, Vue falls back to in-place patching—which works for simple read-only lists but breaks down fast.

A stable key must be:

  • Unique per item: No two list items should share the same key
  • Persistent: The same item must have the same key across renders
  • Derived from the data: Not from array index or transient state

In a real e-commerce scenario, we'd use a product ID or order ID:

typescript
<template>
  <div class="cart-items">
    <div
      v-for="item in cartItems"
      :key="item.id"
      class="cart-item"
    >
      <h3>{{ item.name }}</h3>
      <input v-model.number="item.quantity" type="number" />
      <button @click="removeItem(item.id)">Remove</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const cartItems = ref([
  { id: 101, name: 'Laptop', quantity: 1 },
  { id: 102, name: 'Mouse', quantity: 2 },
])

const removeItem = (id) => {
  cartItems.value = cartItems.value.filter(item => item.id !== id)
}
</script>

The key here is item.id—it's stable, unique, and tied to the data model. When you remove an item or reorder the list, Vue knows exactly which DOM elements to update or destroy.

If your items don't have a natural ID, consider generating one server-side or using a UUID. We've seen teams in ride-hailing platforms use order tokens; travel booking systems use reservation IDs. The domain always provides something better than an index.

Never use index as key for dynamic lists

Using the array index as a key is a common trap. It feels natural at first—every item has a position—but it breaks down the moment your list changes. When you add, remove, or reorder items, indices shift, and Vue's reconciliation becomes unreliable.

Consider a user notifications list that fetches new items from a WebSocket:

typescript
<template>
  <div class="notifications">
    <!-- WRONG: using index -->
    <div
      v-for="(notification, index) in notifications"
      :key="index"
      class="notification"
    >
      <p>{{ notification.message }}</p>
      <input v-model="notification.read" type="checkbox" />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const notifications = ref([
  { id: 1, message: 'Order confirmed', read: false },
])

// WebSocket listener
const addNotification = (msg) => {
  notifications.value.unshift({ id: Date.now(), message: msg, read: false })
}
</script>

When a new notification arrives and is unshifted to the front, every existing index shifts by one. Vue will reuse the wrong DOM nodes—the checkbox state from the old first item might persist on the new first item. The user sees their read state flip unexpectedly.

The fix is straightforward: use :key="notification.id". Vue now maintains the identity correctly, and component state stays with the right item.

We've debugged this in production SaaS dashboards where live data feeds caused checkboxes and form inputs to swap state. The rule is simple: if your list can change (add, remove, reorder), never use index as a key. Static lists are the only exception, and even then, using a stable ID is safer.

Avoid v-if on the same element as v-for

Combining v-if and v-for on the same element creates performance overhead and can produce surprising results. Vue processes v-for before v-if, meaning it renders the entire list first, then filters it—wasting cycles on hidden elements.

Here's what to avoid:

typescript
<template>
  <!-- INEFFICIENT -->
  <div
    v-for="order in orders"
    v-if="order.status === 'pending'"
    :key="order.id"
    class="order-card"
  >
    <h4>{{ order.id }}</h4>
    <p>{{ order.total }}</p>
  </div>
</template>

If you have 1,000 orders but only 50 are pending, Vue still renders all 1,000 and then filters. This is especially painful in a ride-hailing platform with thousands of historical rides or a CRM with thousands of deals.

The solution is to move the filter to a computed property. This way, Vue only renders the items you actually need:

typescript
<template>
  <div class="pending-orders">
    <div
      v-for="order in pendingOrders"
      :key="order.id"
      class="order-card"
    >
      <h4>{{ order.id }}</h4>
      <p>{{ order.total }}</p>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue'

const orders = ref([
  { id: 1001, status: 'pending', total: 49.99 },
  { id: 1002, status: 'shipped', total: 79.99 },
  { id: 1003, status: 'pending', total: 129.99 },
])

const pendingOrders = computed(() =>
  orders.value.filter(order => order.status === 'pending')
)
</script>

Now the filter runs once and caches. Vue only renders pending orders. The computed approach is clearer to readers too—intent is explicit.

If you need multiple filters (pending OR processing), use a single computed that covers all cases. Avoid nesting multiple v-if blocks; it's slower and harder to maintain.

Use computed for filtered lists

Beyond avoiding v-if, using computed for any transformation of your list is a solid pattern. Filtering, sorting, pagination, searching—all belong in computed properties. This keeps your template focused and ensures Vue caches the result efficiently.

Here's a practical example from a SaaS application with user search and filtering:

typescript
<template>
  <div class="users-panel">
    <input
      v-model="searchQuery"
      placeholder="Search by name or email"
      type="text"
    />
    <select v-model="roleFilter">
      <option value="">All roles</option>
      <option value="admin">Admin</option>
      <option value="user">User</option>
    </select>

    <div
      v-for="user in filteredUsers"
      :key="user.id"
      class="user-row"
    >
      <span>{{ user.name }}</span>
      <span>{{ user.email }}</span>
      <span>{{ user.role }}</span>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue'

const users = ref([
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
  { id: 3, name: 'Carol', email: 'carol@example.com', role: 'admin' },
])

const searchQuery = ref('')
const roleFilter = ref('')

const filteredUsers = computed(() => {
  return users.value.filter(user => {
    const matchesSearch =
      !searchQuery.value ||
      user.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
      user.email.toLowerCase().includes(searchQuery.value.toLowerCase())

    const matchesRole =
      !roleFilter.value ||
      user.role === roleFilter.value

    return matchesSearch && matchesRole
  })
})
</script>

The computed caches the result. As the user types, only the final filtered array re-renders. If they switch roles without changing the search query, Vue reuses cached values where possible. This keeps even large lists snappy.

In a trading platform, we used similar computed properties to filter orders by status, side, and price range—100+ items filtered down to 10 or 20 for display. The computed approach meant fast interactions without jank.

Common mistakes

We've seen several patterns that trip up teams:

  • Mutating list items directly without triggering reactivity: If you're fetching new data, always replace the array ref or use methods like splice, push, etc. Assigning a property directly can sometimes fail to trigger updates in Nuxt SSR contexts.
  • Nesting v-for loops without careful key strategy: Each nested loop needs its own stable key. Using index at any level compounds the risk. We've seen nested tables with shifting rows in CRM dashboards because keys weren't unique per level.
  • Forgetting to debounce search input with large lists: A computed that filters 10,000 items on every keystroke will block the UI. Debounce the search query ref or use a separate method call.
  • Using object properties as keys when the object is recreated: :key="item.userId + item.type" works only if those properties are stable. If the object is recreated from an API response, the key will change even if the identity hasn't.

Key takeaways

  • Always provide a stable :key derived from your data model; if your list is dynamic, a unique ID is mandatory
  • Never use array index as a key for lists that can add, remove, or reorder items—it breaks Vue's reconciliation and causes state to shift unexpectedly
  • Move filtering and sorting logic into computed properties; avoid v-if on the same element as v-for to keep renders efficient
  • Computed properties cache results and keep your template clean; use them for any transformation of list data
  • Test your list interactions (add, remove, reorder, filter) in development to catch state bugs early
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles