Handle Unwanted Re-Rendering In Vue

Maybe you've asked yourself before "Why is this component re-rendering?" or "Why is this request being sent again?" or "If only there's a way to explicitly specify which state this component tracks!" then continue reading.

In this article, we will go through when to expect component re-renders and how to avoid unwanted re-rendering. But let's start from the beginning.

How Reactivity Works in Vue/React

At this point the term 'virtual DOM' doesn't scare anyone, it has been the way React, Angular and Vue handle reactivity (this is about to change and we'll talk about it), but let's go through how reactivity works in loose terms.

  • Initially, a VDOM tree is constructed with all the components
  • When the state of a particular component changes, a VDOM sub-tree is re-evaluated
  • Then a diff between the current and the next VDOM is computed
  • Finally, the DOM updates are performed

NOTE: This is not the case for frameworks like Qwik and Solid as they reflect state changes to the DOM directly; more about that in future articles.

In React and Angular, this VDOM sub-tree starts from the component that owns the state change and affects all of its children.
In vue, however, things are a bit different.

Refs (signals before they were cool)

We can think of signals as values that one can read, update, or watch (subscribe to).
Given that description, signals are refs in vue (but y'all like solid).

In a vue component, if that component is reading the value of a ref then it is automatically watching (or subscribing to) that ref as well, which means any component that uses this value will render when that value changes.

In light of the previous statement let's take a look at the following structure

Parent.vuevue
<template>
    <MidComponent :state="state" />
</template>
MidComponent.vuevue
<template>
    <Child :state="state" />
</template>

Again, in light of that statement above, one would assume that if MidComponent doesn't use state, then if it changes that wouldn't cause it to re-render, right? (React devs of course don't think so).

Well no really, the reason being that passing state as props even if they are not used in the component unwraps a ref, which means that the intermediate component is now a subscriber to that ref/signal.

Luckely there is an easy solution for that problem.

Provide / Inject

As we said, passing props to a component even if the component is not using the value of these props results in:

  1. Prop drilling
  2. unwanted rendering (unwanted subscribing to the signal or ref)

The provide / inject pattern solves both of those problems, and here is how it works

Parent.vuevue
<template>
    <MidComponent />
</template>

<script setup>
    import {ref, provide} from "vue";
    const count = ref(0);
    provide("count", count);
</script>
Child.vuevue
<template>
    <span>{{ count }}</span>
</template>

<script setup>
    import { inject } from "vue";
    const count = inject("count");
</script>

Reduce Dependencies With v-once And v-memo

These are rarely used directives in vue, so I believe it is better to describe a use case before we talk about them.
Let's take a look at the following code:

Form.vuevue
<template>
    <form @submit.prevent="handleSubmit">
        <input type="text" placeholder="Username" v-model="username" />
        <input type="email" placeholder="email" v-model="password" />

        <input type="submit" value="Submit" />
    </form>

    <Serializer :username="username" :password="password" :response="response" />
</template>

<script setup>
    import {ref} from "vue"
    const username = ref("")
    const password = ref("")
    const response = ref(null)

    const handleSubmit = () => {
        fetch("/api").then(res => res.json()).then(res => {
            response.value = res
        }).catch(e => {
            // error handling
        })
    }
</script>
Serializer.vuevue
<template>
    <div>
        <p>username: {{ username }}</p>
        <p>password: {{ password }}</p>
        <pre>
            {{ JSON.stringify(response) }}
        </pre>
    </div>
</template>

<script setup>
    defineProps({
        username: String,
        password: String,
        response: Object
    })
</script>

Now, even though Serializer takes in username and password as props, we might not want it to trigger a render every time they change; we might want to change only when we get a different response. We can achieve such behavior using the v-memo directive, like this:

Form.vuevue
<template>
<!-- ... -->
    <Serializer :username="username" :password="password" :response="response" v-memo="[response]" />
<!-- ... -->
</template>

This tells vue that the only dependency is response; if we pass an empty array that would indicate that we only want to render a snapshot of the props, which is what v-once directive does. In that case, the component only renders when its internal state changes, not caring about the parent's state.

V-DOM vs DOM node updates

When we talk about the V-DOM subtree update, even if (at best) we update only a single V-DOM node, that could be a lot of HTML (DOM nodes), a lot of which might not depend on the state.
This is also true if the internal component state changes, the entire component will be re-created, even though most of its contents might be static HTML.
This is always the case whith V-DOM-based frameworks like React, Angular, and Vue.
Vue, however, uses the compiler to reduce the work needed at runtime mainly via two things:

1. Static Hoisting

This gets rid of the problem of static content; the compiler finds chunks of static HTML and creates a staticVNode for them that can be reused in the render function, and here is an example.

<div>
  <p class="vue">Vue.js is Cool</p>
  <p class="solid">Solid.js is also Cool</p>
  <p class="vue">Vue.js is Cool</p>
  <p class="solid">Solid.js is also Cool</p>
  <p class="solid">React is cool</p>
  <p>{{agree}}</p>
</div>
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<p class=\"vue\">Vue.js is Cool</p><p class=\"solid\">Solid.js is also Cool</p><p class=\"vue\">Vue.js is Cool</p><p class=\"solid\">Solid.js is also Cool</p><p class=\"solid\">React is cool</p>", 5)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createElementVNode("p", null, _toDisplayString(_ctx.agree), 1 /* TEXT */)
  ]))
}

The variable _hoisted_1 is a group of all static p tags, there is a separate VNode for the last p tag as it uses state agree.

2. Patch Flags

In a very over simplified way, a patch flag is a piece of meta-data added to the VNode to tell vue exactly what part of that node depends on state and they take the values:

  • 1 for text
  • 2 for class
  • 4 for style
  • 8 for props

You can find the full list on Github patchFlags.ts

Vue then uses this information to further optimize updates, allowing for even more fine-grained control.

Vapor Mode

Vapor mode is a feature now under development by the vue core team, and it is a game changer. It aims at getting rid of the VDOM approach as a whole.

This is inspired by the solid.js approach (also Qwik) as it doesn't use a VDOM; instead, it maps changes directly to DOM nodes.

From a memory prespective you now remove a very large object (the VDOM) from memory gaining significant performance inhancements and you also target specific DOM nodes with your updates instead of updating a full component without the need for static hoisting, it just works.

NOTE: worth mentioning that this feature is opt-in and can be incrementally adopted, so no worries.
I know y'all are still traumatized by the Vue 2 -> 3 update.

This is a huge topic to talk about, so perhaps I'll write an article about it in the future. Until then, you can experiment with vapor mode in the Vapor Mode playground.

Conclusion

We can summarize everything into the following points:

  • Vue uses refs (signals) to know exactly which components to render.
  • When a ref is passed as a prop, the component subscribes to it even if it doesn't read it.
  • You can use Provide / Inject to avoid this behavior and prop drilling.
  • You can define explicitly which refs to watch by using the v-memo directive or opt-out completely by using v-once directive.
  • The compiler does some work to hoist static content and inform runtime of which parts can be changed.
  • Vapor mode is going to allow for more fine-grained reactivity by updating DOM directly instead of VDOM.

References

If you want to learn more about how reactivity works in most modern frameworks, check out "Unveiling the Magic: Exploring Reactivity Across Various Frameworks" by MIŠKO HEVERY

If you want to know how vue refs work and how they relate to signals, check out Connection to Signals on the official vue docs.

If you want to read more about Vapor Mode read The Future of Vue: Vapor Mode by Timi Omoyeni.