If you're a Vue user, you likely know computed properties, and if you are like me, you probably think they are awesome - rightfully so!
To me, computed properties are a very ergonomic and elegant way to deal with derived state - that is: state which is made up from other state (its dependencies). But in some scenarios, they can also have a degrading effect on your performance, and I realized that many people are unaware of that, so this is what this article will attempt to explain.
To make clear what we are talking about when we say "computed properties" in Vue, here's a quick example:
const todos = reactive([
{ title: 'Wahs Dishes', done: true},
{ title: 'Throw out trash', done: false }
])
const openTodos = computed(
() => todos.filter(todo => !todo.done)
)
const hasOpenTodos = computed(
() => !!openTodos.value.length
)
Here, openTodos
is derived from todos
, and hasOpenTodos
is derived from openTodos
. This is nice because now we have reactive objects that we can pass around and use, and they will automatically update whenever the state that they depend on, changes.
If we use these reactive objects in a reactive context, such as a Vue template, a render function or a watch()
, these will also react to the changes of our computed property and update - that's the magic at the core of Vue that we value so much, after all.
Note: I'm using composition API because that's what I like to use these days. The behaviors describes in this article apply to computed properties in the normal Options API just as much, though. Both use the same reactivity system, after all.
What is special about computed properties
There's two things about computed properties that make them special and they are relevant to the point of this article:
- Their results are cached and only need to be re-evaluated once one of its reactive dependencies changes.
- They are evaluated lazily on access.
Caching
A computed property's result is cached. In our example above, that means that as long as the todos
array doesn't change, calling openTodos.value
multiple times will return the same value without re-running the filter method. This is especially great for expensive tasks, as this ensures that the task is only ever re-run when it has to – namely when one of its reactive dependencies has changed.
Lazy Evaluation
Computed properties are also evaluated lazily – but what does that mean, exactly?
It means that the callback function of the computed property will only be run once the computed's value is being read (initially or after it was marked for an update because one of its dependencies changed).
So if a computed property with an expensive computation isn't used by anything, that expensive operation won't even be done in the first place - another performance benefit when doing heavy lifting on a lot of data.
When lazy evaluation can improve performance
As explained in the previous paragraph, lazy evaluation of computed properties is a usually a good thing, especially for expensive operations: It ensures that the evaluation is only ever done when the result is actually needed.
This means that things like filtering a big list will simply be skipped if that filtered result won't be read and used by any part of your code at that moment. Here's a quick example:
<template>
<input type="text" v-model="newTodo">
<button type="button" v-on:click="addTodo">Save</button>
<button @click="showList = !showList">
Toggle ListView
</button>
<template v-if="showList">
<template v-if="hasOpenTodos">
<h2>{{ openTodos.length }} Todos:</h2>
<ul>
<li v-for="todo in openTodos">
{{ todo.title }}
</li>
</ul>
</template>
<span v-else>No todos yet. Add one!</span>
</template>
</template>
<script setup>
const showListView = ref(false)
const todos = reactive([
{ title: 'Wahs Dishes', done: true},
{ title: 'Throw out trash', done: false }
])
const openTodos = computed(
() => todos.filter(todo => !todo.done)
)
const hasOpenTodos = computed(
() => !!openTodos.value.length
)
const newTodo = ref('')
function addTodo() {
todos.push({
title: todo.value,
done: false
})
}
</script>
See This code running on the SFC Playground
Since showList
is initially false
, the template/render function will not read openTodos
, and consequently, the filtering would not even happen, neither initially nor after a new todo has been added and todos.length
has changed. Only after showList
has been set to true
, these computed properties would be read and that would trigger their evaluation.
Of course in this small example, the amount of work for filtering is minimal, but you can imagine that for more expensive operations, this can be a huge benefit.
When lazy evaluation can degrade performance
There is a downside to this: If the result returned by a computed property can only be known after your code makes use of it somewhere, that also means that Vue's Reactivity system can't know this return value beforehand.
Put another way, Vue can realize that one or more of the computed property's dependencies have changed and so it should be re-evaluated the next time it is being read, but Vue can't know, at that moment, wether the result returned by the computed property would actually be different.
Why can this be a problem?
Other parts of your code may depend on that computed property – could be another computed property, could be a watch()
, could be the template/render function.
So Vue has no choice but to mark these dependents for an update as well – "just in case" the return value will be different.
If those are expensive operations, you might have triggered an expensive re-evaluation even though your computed property returns the same value as before, and so the re-evaluation would have been unnecessary.
Demonstrating the issue
Here's a quick example: Imagine we have a list of items, and a button to increase a counter. Once the counter reaches 100, we want to show the list in reverse order (yes, this example is silly. Deal with it).
(You can play with this example on this SFC playground)
<template>
<button @click="increase">
Click me
</button>
<br>
<h3>
List
</h3>
<ul>
<li v-for="item in sortedList">
{{ item }}
</li>
</ul>
</template>
<script setup>
import { ref, reactive, computed, onUpdated } from 'vue'
const list = reactive([1,2,3,4,5])
const count = ref(0)
function increase() {
count.value++
}
const isOver100 = computed(() => count.value > 100)
const sortedList = computed(() => {
// imagine this to be expensive
return isOver100.value ? [...list].reverse() : [...list]
})
onUpdated(() => {
// this eill log whenever the component re-renders
console.log('component re-rendered!')
})
</script>
Question: You click the button 101 times. How often does our component re-render?
Got your answer? You sure?
Answer: It will re-render 101 times*.*
I suspect some of you might have expected a different answer, something like: "once, on the 101st click". But that's wrong, and the reason for this is the lazy evaluation of computed properties.
Confused? We'll walk through what's happening step by step:
- When we click the button, the
count
is increased. The component would not re-render, because we don't use the counter in the template. - But since
count
changed, our computed propertyisOver100
is marked as "dirty" - a reactive dependency changed, and so its return value has to be re-evaluated. - But due to lazy evaluation, that will only happen once something else reads
isOver100.value
- before that happens, we (and Vue) don't know if this computed property will still returnfalse
or will change totrue
. -
sortedList
depends onisOver100
though - so it also has to be marked dirty. And likewise, it won't yet be re-evaluated because that only happens when it's being read. - Since our template depends on
sortedList
, and it's marked as "dirty" (potentially changed, needs re-evaluation), the component re-renders. - During rendering, it reads
sortedList.value
-
sortedList
now re-evaluates, and readsisOver100.value
– which now re-evaluates, but still returnsfalse
again. - So now we have re-rendered the component and re-run the "expensive"
sorteList
computed even though all of that was unnecessary - the resulting new virtual DOM / template will look exactly the same.
The real culprit is isOver100
– it is a computed that often updates, but usually returns the same value as before, and on top of that, it's a cheap operation that doesn't really profit from a the caching computed properties provide. We just used a computed because it feels ergonomic, it's "nice".
When used in another, expensive computed (which does profit from caching) or the template, it will trigger unnecessary updates that can seriously degrade your code's performance depending on the scenario.
It's essentially this combination:
- An expensive computed property, watcher or the template depends on
- another computed property that often re-evaluates to the same value.
How to solve this problem when you come across it.
By now you might have two questions:
- Wow! Is this a bad problem?
- How do I get rid of it?
So first off: Chill. Usually, this is not a big problem.
Vue's Reactivity System is generally very efficient, and re-renders are as well, especially now in Vue 3. usually, a couple unnecessary updates here and there will still perform much better than, say, a React counterpart that by default, re-renders on any state change whatsoever.
So the problem only applies to specific scenarios where you have a mix of frequent state updates in one place, that trigger frequent unnecessary updates in another place that is expensive (very large component, computationally heavy computed property etc).
If you encounter such a situation, you can optimize it with a custom little helper:
NOTE
In a previous version of this article, I orginally had mentioned 3 points here. Two of them were, in the end, not really relevant to the problem but rather remnants of a previous draft I erroneously shoehorned into this.
I removed those for the sake of clarity and getting to the point.
Custom eagerComputed
helper
Vue's Reactivity System gives us all of the required tools to build our own version of a computed()
, one that evaluates eagerly, not lazily.
Let's call it eagerComputed()
import { watchEffect, shallowRef, readonly } from 'vue'
export function eagerComputed(fn) {
const result = shallowRef()
watchEffect(() => {
result.value = fn()
},
{
flush: 'sync' // needed so updates are immediate.
})
return readonly(result)
}
We can then use this like we would use a computed property, but the difference in behavior is that the update will be eager, not lazy, getting rid of unnecessary updates.
Check out the fixed example on this SFC Playground
When would you use computed()
and when eagerComputed()
?
- Use
computed()
when you have a complex calculation going on, which can actually profit from caching and lazy evaluation and should only be (re-)calculated if really necessary. - Use
eagerComputed()
when you have a simple operation, with a rarely changing return value – often a boolean.
Note: Keep in mind that this helper uses a sync watcher, which means it will evaluate on each reactive change synchronously and individually - if a reactive dependency changes 3 times, this will re-run 3 times. So it should only be used for simple and cheap operations.
Finishing up
So this is it. We dove deeper into how computed properties actually work. We learned when they are beneficial for your app's performance, and when they can degrade it. Concerning the latter scenario, we learned how to solve the performance problem by avoiding unnecessary reactive updates with an eagerly evaluating helper.
I hope this was helpful. Let me know if you have questions, and tell me other topics you may want me to cover.
Top comments (23)
My first thought was to put the expensive operation (reversing the list) in its own computed:
This way even though the
sortedList
computed property is updated often, it always uses the cachedreversedList
because Vue knows thatreversedList
only depends on the original list.Good idea, but in the example,
sortedList
was meant to be imagined as an expensive operation, mimicked by[...list]
that operation still happens each time the count is increased and is still <101.So this can be a way to work around the described problem in some scenarios but doesn't completely fit the imagined scenario here.
Hmm yeah, but if it's the sorting that's expensive then you could have a computed property for the sortedList instead. It's the same thing, the idea is to separate the expensive operation into its own computed property.
While you may be right here, I don't think this is supposed to be a perfect example.
I actually encountered similar issue but arrived at a different solution:
Wrote a
useMemo
function that returns a wrapped reference that triggers downstream watcher only when some memorizationkey
from the original reference changed. Hence combining the best ofcomputed
andwatch
.Need to point out that there some difference in my requirement hence
eagerComputed
not suitable. In my caseeagerComputed
will fail.Nice article and nice catch about the computed functions.
Thanks for thorough explanation on how computed props are excecuted and cached.
It's really usefull to bear that lazy nature of computed props in mind when you have lots of them and they depend on each other.
I spotted some mistakes though, which made it difficult to follow the article
According to the example, the
openTodos
relies ontodo.done
prop, and not justtodos.length
. Hence, the highlited statement is wrong.I believe you just forgot to update your example, becase in the end of the article there is another example where
openTodos
does depend ontodos.length
The example in the "When lazy evaluation can improve performance" section is outdated and is not the same as the one used in the Playground:
-- no "Toggle ListView" btn in the template
-- "hasOpenTodos" is not used in the template as well
-- "addTodo" function uses unknown "todo.value" vs "newTodo.value"
Please review the examples and the text
Thanks for the review. I fixed the example in your second point.
About the first point: It's not actually wrong in the place you thought, just an imrecise statement- the filter method would indeed not be run if the length of
todos
doesn't change - except if you would mutate the array with something like.reverse()
or.sort()
.But the example at the end of the article you refer to is indeed a copy-paste error from the initial example, so I fixed that to refer to
openTodos
.Thanks again
I tend to disagree :)
Reactive
todos
is deeply reactive, right?So if you change "done" prop of any of the todos, then the computed
openTodos
will need to be reevaluated and rerun the filter method even though the length stays the same.Argh. It's so hard to come up with good examples 😭
Yes you are right in this case - I wasn't really paying attention to what the filter did. 😬
Will see if I can change it to be more precise.
Excellent work!
Note: If you are using Vue 3.4+, you can straight use computed instead.
Because in Vue 3.4+, if computed new value does not change, computed, effect, watch, watchEffect, render dependencies will not be triggered.
refer: github.com/vuejs/core/pull/5912
This is an excellent article. I'm going from memory here but I could swear that some Vue components I've built in the past always seemed one tick behind where they were supposed to be.
I'm pretty sure that I've had to use watch() to fix the problem but I might also be conflating a slightly different issue with this.
Either way, this was really interesting and I'm glad that you took the time to write about it.
I switched from Vue to React within the past year or so for many of my personal projects and so I've been out of the loop a little bit, but I just took a job working with Vue. I feel like I've missed so much in the past few months. I was still using the options API with Vue 3 and without a setup directive and then I saw Vitesse.
Vue is such an awesome framework.
Glad you found it helpful.
the issue you describe sounds like it's not related to what I am describing here. Lazy evaluation won't cause any timing issues.
Also, don't feel pressures to use
setup()
if you feel comfortable with Options API - i won't disappear or anything.Hmm. I always assumed that since isOver100 would evaluate to false again, that sortedList would not see it as dirty because, although the computed ran again, it didn't change value, therefore sortedList would not have seen a change that would require a re-compute.
Also, this issue could be solved in a similar manner to Valentin Berlier's idea (other commenter), but slightly different:
Then in the template...
Right?
I can think of at least one more possible solution too, even closer to what Valentin stated, but in the end, that's not the point. Your point is to raise awareness of how this works so that we can be mindful and be able to fix/avoid it when we come across it.
An easy fix to this example situation would be to add a
key
on the button with the value ofcounter
Nope, additing key on the button would not make any difference
Can you explain why adding a key solves the problem?
Vue uses keys for deterministic re-evaluation. It's use case is typically for loops, but a key can be used on any component/element. When a key changes, that forces Vue to do a re-evaluation.
Hi, since you have a deep knowledge about computed property, could you write an article about using computed property with argument?
In my project, ive always encounter needing dynamic computed property especially when i want to use computed property in v-for loop where i need to get the index of the current item for the computed property. what i normally do is i created computed property that return a function that has one or more argument. ive read online this is not beneficial and the result are not being cached and the author suggest just to use normal function. so i would like to know is there any alternative to do this?
As of Vue 3.4. this 101 computed example is no longer valid. Component will rerender only once when counter reach 101 value.
blog.vuejs.org/posts/vue-3-4#more-...
Dear Thorsten Lünborg,may I translate your article into Chinese?I would like to share it with more developers in China. I will give the original author and original source.