Migrating From Durandal to Vue: Use shallowRefs Instead of refs When Replacing KnockoutJS Observables

When converting the application I'm working on from Durandal to Vue I had to learn the differences between how Knockout and Vue handle reactivity. This doesn't apply to all instances but it is something to consider when converting objects that contain KnockoutJS observables.

The Vue documentation states:

In Vue, state is deeply reactive by default. This means you can expect changes to be detected even when you mutate nested objects or arrays

This raised an issue as some of the objects in the application are large and only certain members need to be reactive. Converting the ko.observable to ref would not be suitable in this case because it could cause problems with performance.

I've created a Code Sandbox with an example showing this behaviour but the code for the component is:

<template>
  <div>NestedObjectComponent</div>
  <div>Name = {{ refForComponent.name }}</div>
  <div>Inner Name = {{ refForComponent.inner.innerName }}</div>
  <div>Counter Value = {{ refForComponent.inner.innerRef }}</div>
  <div>Inner Name = {{ refForComponent.inner.secondInner.name }}</div>
  <div>Counter Value = {{ refForComponent.inner.secondInner.innerRef }}</div>
  <div>
    Length of Array = {{ refForComponent.inner.secondInner.innerArray.length }}
  </div>
  <div class="container card-container">
    <div
      v-for="item in refForComponent.inner.secondInner.innerArray"
      :key="item.id"
      @click="activateItem(item)"
      class="card"
    >
      <div>Id: {{ item.id }}</div>
      <div>Name: {{ item.name }}</div>
      <div>isActive: {{ item.isActive }}</div>
    </div>
  </div>
  <div class="container button-container">
    <button type="button" class="btn btn-primary" @click="increment">
      Increment
    </button>
    <button type="button" class="btn btn-primary" @click="toggleInnerObject">
      Toggle Inner Object
    </button>
    <button type="button" class="btn btn-primary" @click="incrementSecondInner">
      Increment Second Inner Value
    </button>
    <button type="button" class="btn btn-primary" @click="addItemToArray">
      Add Item To Array
    </button>
  </div>
</template>

<script>
import { ref } from "vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap";

const innerObject1 = {
  innerName: "Inner Object 1",
  innerRef: 5,
  secondInner: {
    name: "Second Inner 1",
    innerRef: 5,
    innerArray: [],
  },
};

const innerObject2 = {
  innerName: "Inner Object 2",
  innerRef: 15,
  secondInner: {
    name: "Second Inner 2",
    innerRef: 15,
    innerArray: [],
  },
};

export default {
  name: "NestedObjectComponent",
  setup() {
    let idCounter = 0;

    const refForComponent = ref({
      name: "refForComponent name value",
      inner: innerObject1,
    });

    const increment = () => {
      refForComponent.value.inner.innerRef++;
      console.log("increment = " + refForComponent.value.inner.innerRef);
    };

    const incrementSecondInner = () => {
      refForComponent.value.inner.secondInner.innerRef++;
    };

    const toggleInnerObject = () => {
      if (refForComponent.value.inner.innerName === "Inner Object 1") {
        refForComponent.value.inner = innerObject2;
      } else {
        refForComponent.value.inner = innerObject1;
      }
    };

    const addItemToArray = () => {
      refForComponent.value.inner.secondInner.innerArray.push({
        id: idCounter,
        name: "Name " + idCounter,
        isActive: false,
      });
      idCounter++;
      console.log("idCounter = " + idCounter);
    };

    const activateItem = (item) => {
      refForComponent.value.inner.secondInner.innerArray.forEach(function (
        itemToDeactivate
      ) {
        itemToDeactivate.isActive = false;
      });
      item.isActive = true;
    };

    return {
      refForComponent,
      increment,
      toggleInnerObject,
      incrementSecondInner,
      addItemToArray,
      activateItem,
    };
  },
};
</script>

<style lang="scss" scoped>
.card {
  border: 1px solid black;
}
.card-container,
.button-container {
  margin-top: 1em;
}
button + button {
  margin-left: 1em;
}
</style>
Example Component displaying ref behaviour with objects

As you can see the only ref in the component is refForComponent but I can update the values in the nested objects using the buttons and the values are updated in the UI.

The solution that has worked for me so far was to use shallowRefs. Referencing the Vue documentation again, it states:

Vue does provide an escape hatch to opt-out of deep reactivity by using shallowRef() and shallowReactive(). Shallow APIs create state that is reactive only at the root level, and exposes all nested objects untouched. This keeps nested property access fast, with the trade-off being that we must now treat all nested objects as immutable, and updates can only be triggered by replacing the root state

This is similar behaviour to Knockout observables. So when updating code I changed ko.observable to shallowRef and then when the value was being changed/read I used the .value property of Vue instead of the function call of Knockout.

var obv = ko.observable(0);
console.log('obv = ' + obv());
obv(10);
console.log('obv = ' + obv());

// Becomes

var obv = shallowRef(0);
console.log('obv = ' + obv.value);
obv.value = 10;
console.log('obv = ' + obv.value);
Example of change from KnockoutJS to Vue.js

So far, so good. I'll try to update this post if I run into any relevant issues in the future.