Abdelrahman Awad

Non-reactive Objects in Vue Composition API

  • #vuejs
  • #javascript
  • #frontend
Published 28 Oct, 2020|

Vue's reactivity magic is one of the most attractive aspects of Vue.js. You define your state and your UI updates whenever the reactive state changes.

But sometimes you don't want reactivity at all, the composition API makes it very easy to do so.

Deep Reactive Conversion

You may ask, why would you even want non-reactive objects?

To answer this question we need to understand something about Vue reactivity. Consider the following data definition that's very common in our Vue.js applications:

{
  data() {
    return {
      products: [
        {
          name: 'Awesome Product',
          price: 50,
          attributes: [...]
        }
      ],
      title: 'Some Page Title'
    };
  }
}

Vue here does more than what meets the eye. To be able to track the changes, Vue roughly has to do the following algorithm:

  1. Iterate over the keys of data return value
  2. If the property is already reactive, skip
  3. If the property is a primitive (non-object or array), re-define it as a reactive property and stop
  4. If the property is an array then iterate over its items and repeat the process from step (1)
  5. If the property is an object, repeat from step (1)

That means Vue recursively traverses all the properties in your object tree and makes sure every property is converted to a reactive version of itself. That is called deep reactive conversion in the Vue docs.

This means the more complex the object is, the larger the overhead of this operation. For most of our cases, this overhead is negligible.

To avoid the overhead, you can use Object.freeze to prevent Vue from making your objects reactive.

With it, you create shallower reactive objects and have fine-grained control over how deep the reactivity goes. Of course, you need to be careful with this as Vue will no longer be able to pick up changes in these deep

{
  data() {
    return {
      products: [
        {
          name: 'Awesome Product',
          price: 50,
          attributes: Object.freeze([...])
        }
      ],
      title: 'Some Page Title'
    };
  }
}

The example freezes the attributes nested property, which means Vue won't pick up any changes in them.

Other properties may change and we want those to be picked up by the reactive system.

Why non-reactive objects

Don't get me wrong, this behavior is not undesirable. This is one of the examples that shows how much thought went into DX and it is far handier to have auto conversions than having an unwieldy API to update the state. So back to our question, when do we actually NOT need some objects to be reactive?

Off the top of my head, I would say configuration objects for some UI libraries. With sliders/carousels, you have to pass in a large object to configure their behavior. Like the number of slides, gaps, number of slides, breakpoints, and other options.

This is what you see in most documentation of such libraries for Vue.js

<template>
  <div>
    <some-vue-slider-lib :config="config"></some-vue-slider-lib>
  </div>
</template>

<script>
  export default {
    data: () => ({
      config: {
        slidesPerView: 3,
        isCentered: true,
        slideGap: 20,
        breakpoints: {
          768: {
            // ...
          },
          1024: {
            // ...
          }
          // ...
        }
      }
    })
  };
</script>

Depending on your needs and the library you are using, such objects can be quite complex. Furthermore, it is unlikely they will update during the lifetime of the component.

This makes the recursive behavior wasteful. I would rather Vue do more productive things. Some of these libraries do not react to changes in their config or their props which is even more wasteful.

How likely you are to run this piece of code in the previous component:

this.config.slidesPerView = 4;

I would say never, I have been using Vue for over 4 years and never had to do something like that. Your case may be different of course.

Another common example is 3rd party class instances. For example I maintain a small GraphQL library called villus and it used to have this API in earlier:

<template>
  <div>
    <provider :client="client"></provider>
  </div>
</template>

<script>
  import { Client } from 'villus';

  export default {
    data: () => ({
      client: new Client({
        url: '/graphql'
        // ...
      })
    })
  };
</script>

This is wasteful even more so than the previous example.

Because 3rd party class instances will never take advantage of being converted to reactive objects.

We can't freeze it because it would break the inner workings of the instance itself as you would imagine there is some internal state there. So you have no way to make them non-reactive. Well, not a straightforward way that is.

3rd party classes can be quite complex, with unexpected depths of the objects. So they can have quite a toll on the performance of the component initialization.

I recommend being careful when using 3rd-party objects in your data.

The last example is private objects, for example:

{
  mounted() {
    this.$intersectionObs = new IntersectionObserver(...);
  }
}

Now that's problematic on many levels. Yes, the $intersectionObs is non-reactive like it should be. But, if anyone browsing your code missed that line, they would have no idea where that property came from.

They would check the data or computed or even the methods and they won't find it which makes your components harder to reason about. Not to mention TypeScript not liking this very much.

So to fix the maintainability issues you may be tempted to do this:

{
  data: () => ({
    intersectionObs: undefined,
  }),
  mounted() {
    this.intersectionObs = new IntersectionObserver(...);
  }
}

But that would make it reactive, which is what we are trying to avoid. Another side effect that it would be exposed to the template which is wasteful because it is unlikely that you need the observer in the template.

Another common case where you don't want to have deep reactive conversion is when you are displaying large lists of data that you don't plan to change. Like tables in a dashboard or listing pages, which is very common with async data.

const users = await this.fetchUsers();
this.users = users; // deep reactive conversion will operate on the users array

In that last sample we just want to track if the users reference changes, not it's contents because that's enough to trigger a re-render and display the new data. Having deep reactivity here serves no purpose there. You can map the items with Object.freeze to do just that but it can be difficult to remember to freeze your objects in the middle of your logic.

Opt-in Reactivity With The Composition API

When I first experimented with the RFC, one of the exciting points for me was it allowed us to be explicit about what is reactive and what to hide from the template.

With the composition API it's reversed. We have to explicitly define the reactive. Otherwise, they are non-reactive by default. We can create reactive stuff using ref and reactive primarily.

// non reactive because it is a plain object
const staticState = { count: 0 };
// reactive because it was defined with `reactive`
const reactiveState = reactive({ count: 0 });
// non reactive
let staticCount = 0;
// reactive
const reactiveCount = ref(0);

Let's go over the last 3 examples and re-write them with the composition API.

For the carousels config we can do something like this:

<template>
  <div>
    <some-vue-slider-lib :config="config"></some-vue-slider-lib>
  </div>
</template>

<script>
  export default {
    setup() {
      const config = {
        slidesPerView: 3,
        isCentered: true,
        slideGap: 20,
        breakpoints: {
          768: {
            // ...
          },
          1024: {
            // ...
          }
          // ...
        }
      };

      return {
        config
      };
    }
  };
</script>

We hardly did anything there, if we needed the config object to be reactive we only have to wrap it with reactive.

Same thing for the second example, we keep the code almost exactly as it is:

<template>
  <div>
    <provider :client="client"></provider>
  </div>
</template>

<script>
  import { Client } from 'villus';

  export default {
    setup() {
      const client = new Client({
        url: '/graphql'
        // ...
      });

      return { client };
    }
  };
</script>

And for the last example, well we don't even have to expose our intersection observer at all:

import { onMounted } from 'vue';

export default {
  setup() {
    let intersectionObs;
    onMounted(() => {
      intersectionObs = new IntersectionObserver(...);
    });
  }
}

As an added bonus we have a function called markRaw which makes sure an object stays non-reactive. You could accidentally make an object reactive by embedding it in another:

const foo = markRaw({});
console.log(isReactive(reactive(foo))); // false

// also works when nested inside other reactive objects
const bar = reactive({ foo });
console.log(isReactive(bar.foo)); // false

I don't imagine markRaw would be used frequently except in a few advanced cases but it's there if you need it.

Shallow Reactivity

The composition API doesn't just allow you to go fully reactive or bust, you can find a middle ground with shallow reactivity.

Shallow reactivity can be achieved with either shallowRef or shallowReactive. Which solves our last use case, if we define our users ref with shallowRef we can effectively avoid the deep conversion without extra mapping or remembering to freeze our objects.

const users = shallowRef([]);

// later on in your code
users.value = await fetchUsers();

shallowReactive on the other hand only makes the defined properties reactive, but not their contents if they are arrays or objects.

Conclusion

Vue's deep reactive conversion operation is extremely handy. Yet may yield unexpected performance drops.

Being picky about what can be reactive allows us to be more conscious about performance.

Forcing us to opt-in for reactivity is one of the benefits of using the composition API and also being able to define boundaries for the deep reactive conversion with shallow reactivity allows us to have just the right amount of control over the powerful magic of Vue.js

Git Gud at Vue

Subscribe to get notified of my latest content

You might also like