How to prevent unnecessary re-renders in Vue
Unnecessary re-renders waste CPU cycles rendering unchanged components, degrading application performance especially with large lists or complex component trees. As the creator of CoreUI, a widely used open-source UI library, I’ve optimized Vue rendering performance in enterprise applications throughout my 11 years of frontend development. The most effective approach combines computed properties for derived state, v-memo directive for conditional rendering, and shallowRef for large immutable data. This method minimizes reactivity overhead, skips unchanged component updates, and reduces JavaScript execution time during render cycles.
Use computed properties, v-memo, shallowRef, and strategic reactivity to prevent unnecessary component re-renders.
<script setup>
import { ref, computed, shallowRef } from 'vue'
// BAD: Inline computation causes re-render on every parent update
const items = ref([
{ id: 1, name: 'Item 1', price: 10 },
{ id: 2, name: 'Item 2', price: 20 },
{ id: 3, name: 'Item 3', price: 30 }
])
// GOOD: Computed property caches result
const totalPrice = computed(() => {
console.log('Computing total price') // Only logs when items change
return items.value.reduce((sum, item) => sum + item.price, 0)
})
const expensiveItems = computed(() => {
console.log('Filtering expensive items')
return items.value.filter(item => item.price > 15)
})
// BAD: Regular ref makes entire array deeply reactive
const largeDataset = ref(Array(10000).fill(null).map((_, i) => ({
id: i,
value: Math.random()
})))
// GOOD: shallowRef only tracks array reference, not contents
const optimizedDataset = shallowRef(Array(10000).fill(null).map((_, i) => ({
id: i,
value: Math.random()
})))
function updateDataset() {
// Replace entire array to trigger update
optimizedDataset.value = optimizedDataset.value.map(item => ({
...item,
value: Math.random()
}))
}
</script>
<template>
<div>
<!-- BAD: Recomputes on every render -->
<p>Total: {{ items.reduce((s, i) => s + i.price, 0) }}</p>
<!-- GOOD: Uses cached computed value -->
<p>Total: {{ totalPrice }}</p>
<!-- Using v-memo to skip re-renders -->
<div
v-for='item in items'
:key='item.id'
v-memo='[item.price]'
>
<!-- Only re-renders when item.price changes -->
<span>{{ item.name }}: ${{ item.price }}</span>
</div>
</div>
</template>
Using v-memo for list optimization:
<script setup>
import { ref } from 'vue'
const todos = ref([
{ id: 1, text: 'Buy groceries', completed: false },
{ id: 2, text: 'Walk dog', completed: true },
{ id: 3, text: 'Write code', completed: false }
])
const selectedId = ref(null)
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
</script>
<template>
<div class='todo-list'>
<!-- Without v-memo: All items re-render when any item changes -->
<div
v-for='todo in todos'
:key='todo.id'
class='todo-item'
>
<input
type='checkbox'
:checked='todo.completed'
@change='toggleTodo(todo.id)'
>
<span :class='{ completed: todo.completed }'>
{{ todo.text }}
</span>
</div>
<!-- With v-memo: Only changed item re-renders -->
<div
v-for='todo in todos'
:key='todo.id'
v-memo='[todo.completed, selectedId === todo.id]'
class='todo-item'
>
<input
type='checkbox'
:checked='todo.completed'
@change='toggleTodo(todo.id)'
>
<span :class='{ completed: todo.completed }'>
{{ todo.text }}
</span>
</div>
</div>
</template>
Using Object.freeze for immutable data:
<script setup>
import { ref } from 'vue'
// Large static configuration that never changes
const config = Object.freeze({
apiUrl: 'https://api.example.com',
features: Object.freeze({
darkMode: true,
notifications: true,
analytics: false
}),
constants: Object.freeze({
maxFileSize: 5242880,
allowedTypes: Object.freeze(['jpg', 'png', 'gif'])
})
})
// Large dataset that doesn't need deep reactivity
const chartData = ref(Object.freeze({
labels: Object.freeze(['Jan', 'Feb', 'Mar', 'Apr', 'May']),
datasets: Object.freeze([
{
label: 'Sales',
data: Object.freeze([12, 19, 3, 5, 2])
}
])
}))
// When updating frozen data, replace entire object
function updateChartData(newData) {
chartData.value = Object.freeze(newData)
}
</script>
Component-level optimization with v-once:
<template>
<div>
<!-- Renders once, never updates -->
<div v-once>
<h1>{{ staticTitle }}</h1>
<p>This content is static and never changes</p>
</div>
<!-- Expensive component rendered once -->
<ComplexChart v-once :data='initialChartData' />
<!-- Dynamic content updates normally -->
<div>
<p>Current count: {{ dynamicCount }}</p>
</div>
</div>
</template>
Optimizing with shallowReactive:
<script setup>
import { shallowReactive, triggerRef } from 'vue'
// Only top-level properties are reactive
const state = shallowReactive({
user: {
name: 'John',
email: '[email protected]'
},
settings: {
theme: 'dark',
language: 'en'
}
})
// BAD: Mutating nested property doesn't trigger update
state.user.name = 'Jane' // Won't trigger re-render
// GOOD: Replace entire object to trigger update
state.user = {
name: 'Jane',
email: '[email protected]'
}
// Alternative: Use ref with shallow object
const shallowState = ref({})
function updateShallowState(newData) {
shallowState.value = { ...shallowState.value, ...newData }
}
</script>
Composable for performance monitoring:
// useRenderTracking.js
import { onUpdated, onMounted } from 'vue'
export function useRenderTracking(componentName) {
let renderCount = 0
let lastRenderTime = Date.now()
onMounted(() => {
console.log(`[${componentName}] Mounted`)
})
onUpdated(() => {
renderCount++
const now = Date.now()
const timeSinceLastRender = now - lastRenderTime
lastRenderTime = now
console.log(
`[${componentName}] Re-render #${renderCount}`,
`(${timeSinceLastRender}ms since last render)`
)
})
return { renderCount }
}
Using render tracking:
<script setup>
import { ref } from 'vue'
import { useRenderTracking } from './useRenderTracking'
const count = ref(0)
const name = ref('John')
// Track renders during development
if (import.meta.env.DEV) {
useRenderTracking('MyComponent')
}
</script>
Strategic computed properties:
<script setup>
import { ref, computed } from 'vue'
const users = ref([
{ id: 1, name: 'Alice', age: 25, active: true },
{ id: 2, name: 'Bob', age: 30, active: false },
{ id: 3, name: 'Charlie', age: 35, active: true }
])
const filter = ref('')
const sortBy = ref('name')
// Chain computed properties for efficiency
const activeUsers = computed(() => {
console.log('Computing active users')
return users.value.filter(u => u.active)
})
const filteredUsers = computed(() => {
console.log('Filtering users')
if (!filter.value) return activeUsers.value
return activeUsers.value.filter(u =>
u.name.toLowerCase().includes(filter.value.toLowerCase())
)
})
const sortedUsers = computed(() => {
console.log('Sorting users')
return [...filteredUsers.value].sort((a, b) => {
if (sortBy.value === 'name') {
return a.name.localeCompare(b.name)
}
return a.age - b.age
})
})
// Only sortedUsers recomputes when dependencies change
</script>
Here the computed properties cache results and only recompute when dependencies change. The v-memo directive skips rendering when specified dependencies remain unchanged. The shallowRef creates shallow reactivity tracking only reference changes, not nested properties. Object.freeze prevents Vue from making objects reactive, eliminating reactivity overhead. The v-once renders element once and never updates it. shallowReactive only tracks top-level properties for large nested objects. Chaining computed properties allows granular caching at each transformation step.
Best Practice Note:
This is the rendering optimization strategy we employ in CoreUI Vue components to maintain 60fps performance with large datasets and complex UIs. Use Vue DevTools performance tab to identify components with excessive re-renders, leverage v-memo primarily for large v-for lists where items have stable identity, apply shallowRef for large static data structures like configuration or read-only API responses, and profile with Chrome DevTools to measure actual performance impact before optimizing.



