On-demand Reactivity in Vue 3

Intro

Apart from admirable performance improvements, the recently released Vue 3 also brought several new features. Arguably the most important introduction is the Composition API. In the first part of this article, we recap the standard motivation for a new API: better code organization and reuse. In the second part, we will focus on less-discussed aspects of using the new API, such as implementing reactivity-based features that were inexpressible in Vue 2’s reactivity system.

We will refer to this as on-demand reactivity. After introducing the relevant new features, we will build a simple spreadsheet application to demonstrate the new expressiveness of Vue’s reactivity system. At the very end, we will discuss what real-world use this improvement on-demand reactivity might have.

What’s New in Vue 3 and Why it Matters

Vue 3 is a major rewrite of Vue 2, introducing a plethora of improvements while retaining backward compatibility with the old API almost in its entirety.

One of the most significant new features in Vue 3 is the Composition API. Its introduction sparked much controversy when it was first discussed publicly. In case you are not already familiar with the new API, we will first describe the motivation behind it.

The usual unit of code organization is a JavaScript object whose keys represent various possible types of a piece of a component. Thus the object might have one section for reactive data (data), another section for computed properties (computed), one more for component methods (methods), etc.

Under this paradigm, a component can have multiple unrelated or loosely related functionalities whose inner workings are distributed among the aforementioned component sections. For example, we might have a component for uploading files that implements two essentially separate functionalities: file management and a system that controls the upload status animation.

The <script> portion might contain something like the following:

export default {
  data () {
    return {
      animation_state: 'playing', 
      animation_duration: 10,
      upload_filenames: [],
      upload_params: {
        target_directory: 'media',
        visibility: 'private',
      }
    }
  },
  computed: {
    long_animation () { return this.animation_duration > 5; }, 
    upload_requested () { return this.upload_filenames.length > 0; }, 
  },
  ...
}

There are benefits to this traditional approach to code organization, mainly in the developer not having to worry about where to write a new piece of code. If we’re adding a reactive variable, we insert it in the data section. If we’re looking for an existing variable, we know it must be in the data section.

This traditional approach of splitting the functionality’s implementation into sections (data, computed, etc.) is not suitable in all situations.

The following exceptions are cited often:

  • Dealing with a component with a large number of functionalities. If we want to upgrade our animation code with the ability to delay the start of the animation, for example, we will have to scroll/jump between all the relevant sections of the component in a code editor. In the case of our file-uploading component, the component itself is small and the number of functionalities it implements is small, too. Thus, in this case, jumping between the sections is not really a problem. This issue of code fragmentation becomes relevant when we deal with large components.

  • Another situation where the traditional approach is lacking is code reuse. Often we need to make a specific combination of reactive data, computed properties, methods, etc., available in more than one component.

  • Vue 2 (and the backward-compatible Vue 3) offer a solution to most of the code organization and reuse issues: mixins.

Pros and Cons of Mixins in Vue 3

Mixins allow the functionalities of a component to be extracted in a separate unit of code. Each functionality is put in a separate mixin and every component can use one or more mixins. Pieces defined in a mixin can be used in a component as if they were defined in the component itself. The mixins are a bit like classes in object-oriented languages in that they collect the code related to a given functionality. Like classes, mixins can be inherited (used) in other units of code.

However, reasoning with mixins is harder since, unlike classes, mixins need not be designed with encapsulation in mind. Mixins are allowed to be collections of loosely bound pieces of code without a well-defined interface to the outer world. Using more than one mixin at a time in the same component might result in a component that is difficult to comprehend and use.

Most object-oriented languages (e.g., C# and Java) discourage or even disallow multiple inheritance despite the fact that the object-oriented programming paradigm has the tools to deal with such complexity. (Some languages do allow multiple inheritance, such as C++, but composition is still preferred over inheritance.)

A more practical issue that may occur when using mixins in Vue is name collision, which occurs when using two or more mixins declaring common names. It should be noted here that if Vue’s default strategy for dealing with name collisions is not ideal in a given situation, the strategy can be adjusted by the developer.This comes at the cost of introducing more complexity.

Another issue is that mixins do not offer something akin to a class constructor. This is a problem because often we need functionality that is very similar, but not exactly the same, to be present in different components. This can be circumvented in some simple cases with the use of mixin factories.

Therefore, mixins are not the ideal solution for code organization and reuse, and the larger the project, the more serious their issues become. Vue 3 introduces a new way of solving the same issues concerning code organization and reuse.

Composition API: Vue 3’s Answer to Code Organisation and Reuse

The Composition API allows us (but does not require us) to completely decouple the pieces of a component. Every piece of code—a variable, a computed property, a watch, etc.—can be defined independently.

For example, instead of having an object that contains a data section that contains a key animation_state with the (default) value “playing,” we can now write (anywhere in our JavaScript code):

const animation_state = ref('playing');

The effect is almost the same as declaring this variable in the data section of some component. The only essential difference is that we need to make the ref defined outside of the component available in the component where we intend to use it. We do this by importing its module to the place where the component is defined and return the ref from the setup section of a component. We’ll skip this procedure for now and just focus on the new API for a moment. Reactivity in Vue 3 doesn’t require a component; it’s actually a self-contained system.

We can use the variable animation_state in any scope that we import this variable to. After constructing a ref, we get and set its actual value using ref.value, for example:

animation_state.value = 'paused';
console.log(animation_state.value);

We need the ‘.value’ suffix since the assignment operator would otherwise assign the (non-reactive) value “paused” to the variable animation_state. Reactivity in JavaScript (both when it is implemented through the defineProperty as in Vue 2, and when it’s based on a Proxy as in Vue 3) requires an object whose keys we can work with reactively.

Note that this was the case in Vue 2, as well; there, we had a component as a prefix to any reactive data member (component.data_member). Unless and until the JavaScript language standard introduces the ability to overload the assignment operator, reactive expressions will require an object and a key (e.g., animation_state and value as above) to appear on the left-hand side of any assignment operation where we wish to preserve reactivity.

In templates, we can omit .value since Vue has to preprocess the template code and can automatically detect references:

<animation :state='animation_state' />

In theory, the Vue compiler could preprocess the <script> portion of a Single File Component (SFC) in a similar way, too, inserting .value where needed. However, the use of refs would then differ based on whether we are using SFCs or not, so perhaps such a feature is not even desirable.

Sometimes, we have an entity (for example, be a Javascript object or an array) that we never intend to replace with a completely different instance. Instead, we might only be interested in modifying its keyed fields. There is a shorthand in this case: using reactive instead of ref allows us to dispense with the .value:

const upload_params = reactive({
  target_directory: 'media',
  visibility: 'private',
});

upload_params.visibility = 'public';    // no `.value` needed here

// if we did not make `upload_params` constant, the following code would compile but we would lose reactivity after the assignment; it is thus a good idea to make reactive variables ```const``` explicitly:
upload_params = {
  target_directory: 'static',
  visibility: 'public',
};

Decoupled reactivity with ref and reactive is not a completely new feature of Vue 3. It was partly introduced in Vue 2.6, where such decoupled instances of reactive data were called “observables.” For the most part, one can replace Vue.observable with reactive. One of the differences is that accessing and mutating the object passed to Vue.observable directly is reactive, while the new API returns a proxy object, so mutating the original object will not have reactive effects.

Vue 3

What is completely new in Vue 3 is that other reactive pieces of a component can now be defined independently too, in addition to reactive data. Computed properties are implemented in an expected way:

const x = ref(5);
const x_squared = computed(() => x.value * x.value);
console.log(x_squared.value); // outputs 25

Similarly one can implement various types of watches, lifecycle methods, and dependency injection. For the sake of brevity, we won’t cover those here.

Suppose we use the standard SFC approach to Vue development. We might even be using the traditional API, with separate sections for data, computed properties, etc. How do we integrate the Composition API’s small bits of reactivity with SFCs? Vue 3 introduces another section just for this: setup. The new section can be thought of as a new lifecycle method (which executes before any other hook—in particular, before created).

Here is an example of a complete component that integrates the traditional approach with the Composition API:

<template>
  <input v-model="x" />
  <div>Squared: {{ x_squared }}, negative: {{ x_negative }}</div>
</template>

<script>
import { ref, computed } from 'vue';

export default {
  name: "Demo",
  computed: {
    x_negative() { return -this.x; }
  },
  setup() {
    const x = ref(0);
    const x_squared = computed(() => x.value * x.value);
    return {x, x_squared};
  }
}
</script>

Things to take away from this example:

  • All the Composition API code is now in setup. You might want to create a separate file for each functionality, import this file in an SFC, and return the desired bits of reactivity from setup (to make them available to the remainder of the component).
  • You can mix the new and the traditional approach in the same file. Notice that x, even though it’s a reference, does not require .value when referred to in the template code or in traditional sections of a component such as computed.
  • Last but not least, notice we have two root DOM nodes in our template; the ability to have multiple root nodes is another new feature of Vue 3.

Reactivity is More Expressive in Vue 3

In the first part of this article, we touched upon the standard motivation for the Composition API, which is improved code organization and reuse. Indeed, the new API’s main selling point is not its power, but the organizational convenience that it brings: the ability to structure the code more clearly. It might seem like that is all—that the Composition API enables a way of implementing components that avoids the limitations of the already existing solutions, such as mixins.

However, there is more to the new API. The Composition API actually enables not just better organized but more powerful reactive systems. The key ingredient is the ability to dynamically add reactivity to the application. Previously, one had to define all the data, all the computed properties, etc. before loading a component. Why would adding reactive objects at a later stage be useful? In what remains we take a look at a more complex example: spreadsheets.

Creating a Spreadsheet in Vue 2

Spreadsheet tools such as Microsoft Excel, LibreOffice Calc, and Google Sheets all have some sort of a reactivity system. These tools present a user with a table, with columns indexed by A–Z, AA–ZZ, AAA–ZZZ, etc., and rows indexed numerically.

Each cell may contain a plain value or a formula. A cell with a formula is essentially a computed property, which may depend on values or other computed properties. With standard spreadsheets (and unlike the reactivity system in Vue), these computed properties are even allowed to depend on themselves! Such self-reference is useful in some scenarios where the desired value is obtained by iterative approximation.

Once a cell’s content changes, all the cells that depend on the cell in question will trigger an update. If further changes occur, further updates might be scheduled.

If we were to build a spreadsheet application with Vue, it would be natural to ask if we can put Vue’s own reactivity system to use and make Vue the engine of a spreadsheet app. For each cell, we could remember its raw editable value, as well as the corresponding computed value. Computed values would reflect the raw value if it’s a plain value, and otherwise, the computed values are the result of the expression (formula) that is written instead of a plain value.

With Vue 2, a way to implement a spreadsheet is to have raw_values a two-dimensional array of strings, and computed_values a (computed) two-dimensional array of cell values.

If the number of cells is small and fixed before the appropriate Vue component loads, we could have one raw and one computed value for every cell of the table in our component definition. Aside from the aesthetic monstrousness that such an implementation would cause, a table with a fixed number of cells at compile-time probably doesn’t count as a spreadsheet.

There are problems with the two-dimensional array computed_values, too. A computed property is always a function whose evaluation, in this case, depends on itself (calculating the value of a cell will, in general, require some other values to already be computed). Even if Vue allowed self-referential computed properties, updating a single cell would cause all cells to be recomputed (regardless of whether there are dependencies or not). This would be extremely inefficient. Thus, we might end up using reactivity to detect changes in the raw data with Vue 2, but everything else reactivity-wise would have to be implemented from scratch.

Modeling Computed Values in Vue 3

With Vue 3, we can introduce a new computed property for every cell. If the table grows, new computed properties are introduced.

Suppose we have cells A1 and A2, and we wish for A2 to display the square of A1 whose value is the number 5. A sketch of this situation:

let A1 = computed(() => 5);
let A2 = computed(() => A1.value * A1.value);
console.log(A2.value); // outputs 25

Suppose we stay in this simple scenario for a moment. There is an issue here; what if we wish to change A1 so that it contains the number 6? Suppose we write this:

A1 = computed(() => 6);
console.log(A2.value); // outputs 25 if we already ran the code above

This didn’t merely change the value 5 to 6 in A1. The variable A1 has a completely different identity now: the computed property that resolves to the number 6. However, the variable A2 still reacts to changes of the old identity of the variable A1. So, A2 shouldn’t refer to A1 directly, but rather to some special object that will always be available in the context, and will tell us what is A1 at the moment. In other words, we need a level of indirection before accessing A1, something like a pointer. There are no pointers as first-class entities in Javascript, but it’s easy to simulate one. If we wish to have a pointer pointing to a value, we can create an object pointer = {points_to: value}. Redirecting the pointer amounts to assigning to pointer.points_to, and dereferencing (accessing the pointed-to value) amounts to retrieving the value of pointer.points_to. In our case we proceed as follows:

let A1 = reactive({points_to: computed(() => 5)});
let A2 = reactive({points_to: computed(() => A1.points_to * A1.points_to)});
console.log(A2.points_to); // outputs 25

Now we can substitute 5 with 6.

A1.points_to = computed(() => 6);
console.log(A2.points_to); // outputs 36

On Vue’s Discord server, the user redblobgames suggested another interesting approach: instead of using computed values, use references that wrap regular functions. This way, one can similarly swap the function without changing the identity of the reference itself.

Our spreadsheet implementation will have cells referred to by keys of some two-dimensional array. This array can provide the level of indirection we require. Thus in our case, we won’t require any additional pointer simulation. We could even have one array that does not distinguish between raw and computed values. Everything can be a computed value:

const cells = reactive([
  computed(() => 5),
  computed(() => cells[0].value * cells[0].value)
]);

cells[0] = computed(() => 6);
console.log(cells[1].value); // outputs 36

However, we really want to distinguish raw and computed values since we want to be able to bind the raw value to an HTML input element. Furthermore, if we have a separate array for raw values, we never have to change the definitions of computed properties; they will update automatically based on the raw data.

Implementing the Spreadsheet

Let’s start with some basic definitions, which are for the most part self-explanatory.

const rows = ref(30), cols = ref(26);

/* if a string codes a number, return the number, else return a string */
const as_number = raw_cell => /^[0-9]+(.[0-9]+)?$/.test(raw_cell)  
    ?  Number.parseFloat(raw_cell)  :  raw_cell;

const make_table = (val = '', _rows = rows.value, _cols = cols.value) =>
    Array(_rows).fill(null).map(() => Array(_cols).fill(val));

const raw_values = reactive(make_table('', rows.value, cols.value));
const computed_values = reactive(make_table(undefined, rows.value, cols.value));

/* a useful metric for debugging: how many times did cell (re)computations occur? */
const calculations = ref(0);

The plan is for every computed_values[row][column] to be computed as follows. If raw_values[row][column] doesn’t start with =, return raw_values[row][column]. Otherwise, parse the formula, compile it to JavaScript, evaluate the compiled code, and return the value. To keep things short, we’ll cheat a bit with parsing formulas and we won’t do some obvious optimizations here, such as a compilation cache.

We will assume that users can enter any valid JavaScript expression as a formula. We can replace references to cell names that appear in user’s expressions, such as A1, B5, etc., with the reference to the actual cell value (computed). The following function does this job, assuming that strings resembling cell names really always identify cells (and are not a part of some unrelated JavaScript expression). For simplicity, we will assume column indices consist of a single letter.

const letters = Array(26).fill(0)
    .map((_, i) => String.fromCharCode("A".charCodeAt(0) + i));

const transpile = str => {
    let cell_replacer = (match, prepend, col, row) => {
        col = letters.indexOf(col);
        row = Number.parseInt(row) - 1;
        return prepend + ` computed_values[${row}][${col}].value `;
    };
    return str.replace(/(^|[^A-Z])([A-Z])([0-9]+)/g, cell_replacer);
};

Using the transpile function, we can get pure JavaScript expressions out of expressions written in our little “extension” of JavaScript with cell references.

The next step is to generate computed properties for every cell. This procedure will occur once in the lifetime of every cell. We can make a factory that will return the desired computed properties:

const computed_cell_generator = (i, j) => {
    const computed_cell = computed(() => {
        // we don't want Vue to think that the value of a computed_cell depends on the value of `calculations`
        nextTick(() => ++calculations.value);
      
        let raw_cell = raw_values[i][j].trim();
        if (!raw_cell || raw_cell[0] != '=') 
            return as_number(raw_cell);
      
        let user_code = raw_cell.substring(1);
        let code = transpile(user_code);
        try {
            // the constructor of a Function receives the body of a function as a string
            let fn = new Function(['computed_values'], `return ${code};`);
            return fn(computed_values);
        } catch (e) {
            return "ERROR";
        }
    });
    return computed_cell;
};

for (let i = 0; i < rows.value; ++i)
    for (let j = 0; j < cols.value; ++j)
        computed_values[i][j] = computed_cell_generator(i, j);

If we put all of the code above in the setup method, we need to return

{raw_values, computed_values, rows, cols, letters, calculations}

Below, we present the complete component, together with a basic user interface.

The code is available on GitHub, and you can also check out the live demo.

<template>
  <div>
    <div style="margin: 1ex;">Calculations: {{ calculations }}</div>
    <table class="table" border="0">
      <tr class="row">
        <td id="empty_first_cell"></td>

        <td class="column"
            v-for="(_, j) in cols" :key="'header' + j"
        >
          {{ letters[j] }}
        </td>
      </tr>

      <tr class="row"
          v-for="(_, i) in rows" :key="i"
      >
        <td class="column">
          {{ i + 1 }}
        </td>

        <td class="column"
            v-for="(__, j) in cols" :key="i + '-' + j"
            :class="{ column_selected: active(i, j), column_inactive: !active(i, j),  }"
            @click="activate(i, j)"
        >
          <div v-if="active(i, j)">
            <input :ref="'input' + i + '-' + j"
                   v-model="raw_values[i][j]"
                   @keydown.enter.prevent="ui_enter()"
                   @keydown.esc="ui_esc()"
            />
          </div>
          <div v-else v-html="computed_value_formatter(computed_values[i][j].value)"/>
        </td>
      </tr>
    </table>
  </div>
</template>

<script>
import {ref, reactive, computed, watchEffect, toRefs, nextTick, onUpdated} from "vue";

export default {
  name: 'App',
  components: {},
  data() {
    return {
      ui_editing_i: null,
      ui_editing_j: null,
    }
  },
  methods: {
    get_dom_input(i, j) {
      return this.$refs['input' + i + '-' + j];
    },
    activate(i, j) {
      this.ui_editing_i = i;
      this.ui_editing_j = j;
      nextTick(() => this.get_dom_input(i, j).focus());
    },
    active(i, j) {
      return this.ui_editing_i === i && this.ui_editing_j === j;
    },
    unselect() {
      this.ui_editing_i = null;
      this.ui_editing_j = null;
    },
    computed_value_formatter(str) {
      if (str === undefined || str === null)
        return 'none';
      return str;
    },
    ui_enter() {
      if (this.ui_editing_i < this.rows - 1)
        this.activate(this.ui_editing_i + 1, this.ui_editing_j);
      else
        this.unselect();
    },
    ui_esc() {
      this.unselect();
    },
  },
  setup() {

    /*** All the code we wrote above goes here. ***/

    return {raw_values, computed_values, rows, cols, letters, calculations};
  },
}
</script>

<style>
.table {
  margin-left: auto;
  margin-right: auto;
  margin-top: 1ex;
  border-collapse: collapse;
}

.column {
  box-sizing: border-box;
  border: 1px lightgray solid;
}

.column:first-child {
  background: #f6f6f6;
  min-width: 3em;
}

.column:not(:first-child) {
  min-width: 4em;
}

.row:first-child {
  background: #f6f6f6;
}

#empty_first_cell {
  background: white;
}

.column_selected {
  border: 2px cornflowerblue solid !important;
  padding: 0px;
}

.column_selected input, .column_selected input:active, .column_selected input:focus {
  outline: none;
  border: none;
}
</style>

What About Real-world Use?

We saw how the decoupled reactivity system of Vue 3 enables not only cleaner code but allows more complex reactive systems based on the Vue’s new reactivity mechanism. Roughly seven years have passed since Vue was introduced, and the rise in expressiveness was clearly not highly sought after.

The spreadsheet example is a straightforward demonstration of what Vue is capable of now, and you can also check out the live demo.

But as a real-word example, it is somewhat niche. In what sort of situations might the new system come in handy? The most obvious use-case for on-demand reactivity might be in performance gains for complex applications.

Vue 3

In front-end applications that work with a large amount of data, the overhead of using poorly thought-through reactivity might have a negative impact on performance. Suppose we have a business dashboard application that produces interactive reports of the company’s business activity. The user can select a time range and add or remove performance indicators in the report. Some indicators may display values that depend on other indicators.

One way to implement report generation is through a monolithic structure. When the user changes an input parameter in the interface, a single computed property, e.g., report_data, gets updated. The computation of this computed property happens according to a hardcoded plan: first, calculate all the independent performance indicators, then those that depend only on these independent indicators, etc.

A better implementation will decouple bits of the report and compute them independently. There are some benefits to this:

  • The developer does not have to hardcode an execution plan, which is tedious and error-prone. Vue’s reactivity system will automatically detect dependencies.
  • Depending on the amount of data involved, we might get substantial performance gains since we are only updating the report data that logically depended on the modified input parameters.

If all performance indicators that may be a part of the final report are known before the Vue component gets loaded, we may be able to implement the proposed decoupling even with Vue 2. Otherwise, if the backend is the single source of truth (which is usually the case with data-driven applications), or if there are external data providers, we can generate on-demand computed properties for every piece of a report.

Thanks to Vue 3, this is now not only possible but easy to do.

src.: On-demand Reactivity in Vue 3


author: Luka Mikec