Prop Stability
Intro
In Vue, a child component will always re-render whenever any of the props that it receives changes. This is a common practice but many times it can lead to unneccesary updates of our components, decreasing the performance of our web apps. So let's dive into a best practice that can help us optimize this.
Example
The following example consists of two components: List
(Parent) and ListItem
(Child). Together they display a button and a simple data list. Every time we click on the button, we change the activeId
value. The child component then checks this to determine whether to display an -->
next to the active item's name in the template. Lastly, we use the onUpdated
lifecycle hook to log to the console every time this triggers an update.
<script setup lang="ts">
const stack = [
{ id: 0, name: 'vue' },
{ id: 1, name: 'nuxt' },
{ id: 2, name: 'pinia' },
{ id: 3, name: 'tailwind' },
{ id: 4, name: 'typescript' },
{ id: 5, name: 'vitest' },
];
const activeId = ref(0);
function onClick() {
activeId.value = (toValue(activeId) + 1) % stack.length;
}
</script>
<template>
<button @click="onClick">Next Unstable</button>
<UnstableListItem
v-for="tech in stack"
:key="tech.id"
v-bind="{ ...tech, activeId }"
/>
</template>
You can expect something like this in your browser.
-----------------
| Next Unstable |
-----------------
● --> vue
● nuxt
● pinia
● tailwind
● typescript
● vitest
Problem
Every time you click on the Next Unstable
button you will see the following output in the console.
unstable update: vue
unstable update: nuxt
unstable update: pinia
unstable update: tailwind
unstable update: typescript
unstable update: vitest
This isn't right. We clicked once and all six instances got updated. That's a lot of unnecessary work.
What's worse is that this performance issue will only exponentially grow with the size of the data set. I know this issue might sound trivial with our example but I had to dig into this while working with a list that ranged from 180 to 1,800 items. With the smaller amount it was noticeable. The bigger would bring the browser to its knees. So I had to fix it.
Insight
Whenever I come across code like this, I try to think of how I can stabilize the prop. It normally begins by looking into what is actually done with the prop in the child component.
Looking closer, we find the following
const isActive = computed(() => props.activeId === props.id);
Which is used to determine whether we display the following in the template
<span v-if="isActive"> --> </span>
The first hint we can pick up here is that we might be mixing scope boundries. ActiveId relates to the data set and the child component relates only to the iterable of such set. This is what makes this prop unstable for our loop. We are passing a value that changes often without considering its relevance to the receiving instance. Since our logic condition is inside the child, we need an update to re-calculate if this new value is even useful, which is a very wasteful mechanism.
From all the values of
activeId
, only the value of0
is relevant to the instance of the child component with the scope ofvue
orstack[0]
. A value of1
is only useful to the component with the scope ofnuxt
orstack[1]
, and so forth. All other values are irrelevant and thus trigger unnecessary updates.
All of this could go away if we find a way of translating the context of our prop from the set to the instance scope.
This begins by removing activeId
from the child component. Then let's think about a prop that could be more relevant to the instance we are iterating through. Our answer is in the template: isActive
. This is a better prop because it can be tied directly to the scope of our instace and can be determined outside our component.
If we think about it, creating a story (Storybook) of this component will already expose this unfavorable coupling. We are not only trespassing a boundry (made visible by the performance hindrance) but mixing business and implementation logic (made visible by the story) as well.
Next, we need to move our logical condition out of the child component and place it at the same level of its state. By allowing it to accept an ID as a parameter, we can scope it to each instance in our loop.
const activeId = ref(0);
function isActive(id: number) {
return id === toValue(activeId);
}
Finally we use this new method in our loop to evaluate our condition and pass its result into the child component as our isActive prop.
<StableListItem
v-for="tech in stack"
:key="tech.id"
v-bind="{ ...tech, isActive: isActive(tech.id) }"
/>
This stabilizes our prop because we no longer react to every single change of activeId
. Our improved design keeps the value of isActive
stable or unchanged in 4
out of our 6
cases for each item in our example. Now it's value will only change twice, when it becomes active and when it deactives. This is far more performant because updates are no longer tied to the number of items in the data set (6, as with activeId) but rather to the fixed states of toggling a boolean through our comparison logic (2, activation and deactivation). Every time activeId value changes and isActive remains stable we save an unnecessary update.
Visualization
The graphic below allows us to better visualize our improvement:
Every isActive
that starts false will stays false (gray) until it turns true (red, 1st update). Then it will return to false from true (orange, 2nd update). Yellow is used to indicate the deactivation of the last item because clicking on Next while on the last item returns us to the first item of the set.
As a bonus, we also improved the design of the child component because it is no longer coupled to any specific implementation logic that would determine what makes it active. This dumbs down our child component, which is always a good thing for testing and reusability.
Solution
This is how our improved code looks like.
<script setup lang="ts">
const stack = [
{ id: 0, name: 'vue' },
{ id: 1, name: 'nuxt' },
{ id: 2, name: 'pinia' },
{ id: 3, name: 'tailwind' },
{ id: 4, name: 'typescript' },
{ id: 5, name: 'vitest' },
];
const activeId = ref(0);
function isActive(id: number) {
return id === toValue(activeId);
}
function onClick() {
activeId.value = (toValue(activeId) + 1) % stack.length;
}
</script>
<template>
<button @click="onClick">Next Stable</button>
<StableListItem
v-for="tech in stack"
:key="tech.id"
v-bind="{ ...tech, isActive: isActive(tech.id) }"
/>
</template>
Now every time we click on the button Next Stable
the console would log
stable update: vue // old active
stable update: nuxt // new active
If we would click again now it would log
stable update: nuxt // old active
stable update: pinia // new active
Nice, we just got rid of all unnecessary updates by stabilizing our prop. In our example we went from 6 updates down to only 2. However, this optimization would have even more of an impact with bigger data sets. Given that now, in every change we would only update two child components: the old active item and the new active item. Mission accomplished!
The best practice of prop stability took our code from O(n) to O(1), which is vuesome!
Refactor
Now that we have resolved our performance issues without affecting the functionality required by our users. We can take things to the next level by refactoring our code. This is the perfect time to do so because we finally have all the functional and non-functional pieces on the table. And yes, even though we just finished the work, we can address technical debt already.
You can jump right into the final code of the refactor with the button below:
Open PlaygroundOr you can continue reading and join me through the whole refactoring process:
Continue Reading