Optimizing update speeds in Vue.js

Written Saturday, October 26th, 2024

Topics: Javascript, Vue 3, Pinia, Optmization

Contents

Disclaimer: This article assumes you already have a solid grasp on Vue. I use a lot of terminology that may not be clear for someone just starting out.

Preamble

If you just want the actual tips, feel free to skip this section and go straight to the good stuff.

At work recently I've been working on a somewhat complex Vue application. Without going into too much detail, it is effectively an app that lets users edit settings stored in YAML files that they didn't write, without having to actually know how to write YAML.

The actual structure of these settings usually isn't too complex, and most of what users actually want to edit are just numerical values, or other primitive types. However, there are more complex settings entries, including arbitrary arrays and dictionaries, and so the app needs to be able to handle this. There are also often just, a lot of settings entries to be edited, which has implications for performance as well as UX.

Throw in searching, input validation, undo capabilities, and some other quirks/requirements specific to this particular application, and it turns out that this app needs to do some non-trivial state management, both in terms of size and complexity. One would think that I would make use of something like Pinia to make my life easier, but I was new to Vue when I started and so I did not, at least not initially (I'll get to that later).

The bare-bones list of steps this app has to do goes something like:

  1. Load and parse the YAML settings file.
  2. Display these settings to the user in a way that is easy and intuitive to edit.
  3. When they do make an edit, perform input validation, update the state, update the YAML output, and handle some other application-specific stuff.
  4. Update the vDOM with any relevant changes.
  5. Let the browser update and render the real DOM.

When I finally got all this implemented and working, I found that update speeds were terribly slow, resulting in input lag that is noticeable (~300ms) with small files, and downright unusable (multi-second) with larger ones. Some quick profiling with console.time() revealed that steps 2 and 3 were only taking 1-2ms each, and that it was step 4, updating the vDOM that was really causing the slowdown. I would later run into slowdowns from step 5 too, but that wasn't nearly as bad, and many of the things that speed up step 4 will speed up step 5.

Handy tip for profiling

Handy tip: You can use console.time() and Vue's nextTick() function to time how long it takes to updatethe DOM by doing something like:

// some state update happens here
console.time('timer')
// this will run once the vDOM has finished updating
nextTick(() => console.timeEnd('timer'))

So, the number one optimization tip I have? Minimize the number of vDOM updates that happen. This includes both during the initial render of the page, as well as when the state changes and the vDOM has to be patched and re-rendered. This article will focus mostly on the latter.

We'll be working to optimize this part of the Vue component lifecycle. Click on the image to view the full thing.

Reducing vDOM updates using v-memo

Probably the most useful tool I found for reducing vDOM updates is the v-memo directive. From the Vue.js docs:

Memoize a sub-tree of the template. Can be used on both elements and components. The directive expects a fixed-length array of dependency values to compare for the memoization. If every value in the array was the same as last render, then updates for the entire sub-tree will be skipped.
Basically, you attach it to an element/component and give it an array of values. Whenever that component is about to be updated, Vue will check that array, and if none of the values have changed, the component's update will be skipped.

This is really handy when rendering lots of components using v-for, which is what's happening in my application. Each entry in the YAML settings file gets a compinent for editing it, and each of those components contain multiple other components and elements, varying based on the data type of the setting being edited.

When a setting entry was edited, the application state would be updated. This would cause the v-for loop, which depended on that state, to re-render, which would cause all of the components it contained to re-render, which caused a huge slowdown. In most cases, this is mostly mitigated by the key attribute. But in my application at least, key would not suffice, as there's more than one value that each component is dependent on. It also has some other quirks that pop up when adding-removing items from the array you're rendering with the v-for loop.

So in comes v-memo to save the day! By memoizing each setting entry using a few different values*, the vast majority of them wouldn't bother updating when the state changed. This quickened update times significantly, and almost single-handedly solved the performance issues outright. It took some fiddling to figure out eactly what values I had to use, but once I did, it worked great.

Using a proper store to manage application state

This one will require a bit more of an explanation of my application, as well as how state is passed to components in Vue in general

In vanilla Vue, you usually pass state data into a component as a prop. If that prop is updated, Vue will by default re-render the component to reflect the updated state. However, this relationship is almost always one way. A parent component can update a prop being passed to its child, but the child component will not be able to update that prop itself. Technically, if the prop is an object, then the child will be able to update sub-properties of that prop, but this is considered bad practice.

In my settings application, a component is rendered for each setting that has some kind of input that lets you update the value of that setting. The value is passed to the setting from the application state to the component through a prop. However, these components have no actual way of updating that value themselves when the user changes the input, since a component cannot modify the values of its own props.

The simplest way to handle this in vanilla Vue is using signals. When the user updates the value, emit a signal to the parent with the updated value. Then, have the parent pass the new, updated value through to the component as a prop. This works okay for simpler component structures, but in my application, the input components are nested pretty deep, and so are a ways away from where the application state is being held.

Since signals emitted from components don't propagate up automatically, this means I have to emit the signal, catch it in the parent, emit it again, catch it again, etc until it finally reaches the component that's tracking the application state. Then, that component passes the new state as a prop to its child, which passes the state as a prop it its child, etc. This whole process is messy code-wise, and also results in more components being updated than really need to be, which slows things down. If we're just changing the value if a text input, we shouldn't have to update all of its ancestors as well.

The solution is to use a proper store for state management. I used Pinia for this, which has replaced Vuex as the officially supported store for Vue. By using a proper store, the application state is living outside of a component, and instead exists as a singleton object that we can access and edit from anywhere. Now instead of having to data down to a component via props, and then back up via signals, we can just tell the component where in the store its state will be, and have it update it directly.

Switching over to Pinia required some non-trivial refactoring, and just like v-memo, it introduced a few new quirks that needed to be ironed out. However, it wasn't that bad, and it made the application as a whole simpler and easier to understand, as well as faster, since I had cut down on a bunch of component updates.

Conclusion

There were a few other tricks I used to minimize the update lag, or at least make it less noticeable. For example, adding a small bit of debounce to most input fields (only ~50ms or so) made it so that what little input lag was left would go mostly unnoticed by users.

The biggets takeaway you shoudl get from this is to minimize vDOM updates. They're expensive, and especially in more complex applications, they can add up quick. v-memo and proper state management are great ways to do this, but there are others as well. If you have any tricks you use, feel free to send them to me and I'll update this article accordingly.