Vue 2.7 and Composition API with Vue 2 class components

I’m super excited about the new Vue 2.7 minor release. I’m probably more excited than it’s worth but I am. You may think “wait, we have Vue 3, why should I care about some minor new minor version of Vue 2?” and it’s a great question! If you are using Vue 3, your level of excitement will probably be no greater than when watching the grass grow.

If you are stuck with Vue 2 for some reasons, Vue 2.7 is a great opportunity to try out Composition API (which is natively build into this version - no plugin is required). Without any config you can start using “script setup” syntax! With Composition API you can start using new libraries that works for both Vue 2 and Vue 3 but shipping just composables (e.g. vue-use). You can also create your own composables as an alternative to mixins or renderless components.

Using Composition API was possible in Vue 2 previously with a plugin (https://github.com/vuejs/composition-api). The biggest difference from using a plugin shows up when you use Typescript. Vue 2 was migrated to Typescript itself with version 2.7 and when you start using defineComponent or “script setup” to define your components your component types will be compatible with Vue 3. It may be some step towards the migration.

Class components and what to do with them

If you're using Vue 2 and you're also using Typescript, there's a good chance you're also using class components. For Vue 2 it was probably the easiest way to achieve some type-safety for your application. Another option was to use Vue.extend function which had its drawbacks (e.g. requirement to type the returned value of computed properties).

In my current project we do have class-style components. I’m not very happy about it but it is what it is. It’s a large project now and time to implement major changes is counted in months. Of course, such changes must also have solid arguments to back them up. So far, I haven't had a good enough argument to start incrementally dropping class components. Perhaps I could convince the team, but I was not able to convince myself.

Right now I finally have some. At some point we want to migrate to Vue 3. Vue 3 can give us more stability with the full type-safety (including templates). Ecosystem is slowly moving towards Composition API being the default style of writing components. I can imagine that class-style components even though they were not very popular they will be even less - the entry threshold for our project will increase. Composition API also gives new possibilities when it comes to separating and reusing code, possibilities which in Options API had to be solved by not always perfectly matched patterns such as renderless components or mixins.

Ok, I’m convinced and I want to start using Composition API!

So… where do I start?

Using Composition API inside of class component - the complete journey to make it possible

First step: Let’s update Vue to version 2.7!

Done.

Second step: Start writing new components with Composition API (script setup).

Done. (resolution in my mind)

Third step: Incrementally rewrite components to the Composition API when you need to change them in some major way.

2 years later: Done.

Voilà! 🎉

Well… almost.

One missing piece

One missing piece... When I start extracting some logic to composables, I would need a way to use them in legacy class components. Example: I need to add a global counter to the application that can be incremented from any place. I decided to create a composable:

function useCounter() {  const count = ref(0);  const increment = () => count.value++;  return {    count,    increment,  };};

Now I need to add it to a couple of components. The thing is that I don't necessarily want to refactor all of those components to Composition API at once but I need to use a functionality returned by this composable in the component methods (class members) or in the template part.

Vue class components allows to add a setup function in the @Component decorator but it have one major disadvantage: Typescript is not aware of it so if I return something there, it won't be available on the instance (from Typescript perspective).

For me it's a no-go, so I started to thinking how to make it possible to be able to define setup function in a type-safe way for a class component but also how to be able to use returned value within a class-style component with a typed way.

Let's get inspired

The first idea was to get inspired by what vue-class-component suggested for vue 3. The syntax looks as follows

export default class MyClassComponent extends Vue {  setupContext = setup(() => {    const { count, increment } = useCounter();    return { count, increment };  });}

I think it looks pretty neat but the problem with this approach is that it actually requires some internal changes in vue-class-component. So let’s try to figure out how to get something similar with the tools we have available and without changing the library itself.

First idea

Setup for vue component is a function so writing it as a class method would make some sense. In the same way we define lifecycle hooks in class components (e.g. mounted or created).

@Componentexport default class MyClassComponent extends Vue {  setup() {    const { count, increment } = useCounter();    return { count, increment };  }}

First problem: we don't want to call it manually - it needs to be called by Vue before component is created.

vue-class-component gives as a function to create a decorator. Adding a decorators for view options may be familiar for you especially if you did used vue-property-decorator library. This library gives you a decorators for props, watchers, emmits, template refs etc. The great thing about decorators is that those are actually called before creating a component so it can change the component definition.

We can create a decorator @Setup and inside of it set the setup property of component options to the method definition.

import { createDecorator } from 'vue-class-component';export const Setup = createDecorator((options, key) => {  // get decorated method definition  const setupFunction = options.methods[key];  // set component `setup` option  options.setup = setupFunction;});// ...@Componentexport default class MyClassComponent extends Vue {  @Setup setup() {    const { count, increment } = useCounter();    return { count, increment };  }}

You can test it and it actually works, but this is not enough. We can now use Composition API but we still have no (typed) access to what setup returns so currently it's nothing more than having a setup property in @Component decorator. Except it's probably worst (explained below).

A little improvement

Our @Setup decorator is a powerful tool. We can not only add something to the component options but we can also change something that is already there. What if we, after adding setup option, remove the original definition of a method and replace it with some “accessor” to get setup context?

When you're using Composition API together with Options API, properties of the object that have been returned from setup function are added to the component instance so we can access them with this within Options API. When you console.log the component you can see that they are there, but Typescript is not aware of them. Fortunately or unfortunately we have access to the setup function even though the component is already created. We shouldn’t ever call it manually so probably we shouldn't have access to it.

Solution: Let’s use decorator to change the behavior of original method!

Notice that return value of the setup method is pretty much the thing we want to have access to and we already copied original method to be a setup option for a component so we don’t need this method to work like defined. The idea for improved @Setup decorator is as follow:

  1. Copy original method to be setup option for a component
  2. Replace the original method to be an accessor to the setup context (returned value of setup)

Keep in mind that decorators cannot influence the type of decorated property or method so we can replace it’s definition but the return type should remain the same.

export const Setup = createDecorator((options, key) => {  const setupFunction = options.methods[key];  options.setup = setupFunction;  // replace original method with a getter to `this`  options.methods[key] = function () {    return this;  };});// ...@Componentexport default class MyClassComponent extends Vue {  @Setup setup() {    const { count, increment } = useCounter();    return { count, increment };  }}

Now, within Options API e.g. inside of some method we can write this.setup().increment() to increment a counter. Setup function is called exactly once by Vue. We can also access count to get the current value of a counter.

Well… Almost.

this.setup().counter.value; // => value: undefined, Typescript type: numberthis.setup().counter; // => value: 0, Typescript type: Ref<number>

Explanation: To simplify usage of setup output, Vue unwraps refs returned by setup while adding them to the instance. This means that if we return a ref named count from the setup, we can access it’s value directly under this.count - no .value anymore. So the problem with our current code is that type of the value returned by the method does not match the type of the accessor we replaced it with.

So...? We need some hacky solution to fool Typescript! 😁

Hot fix

The easiest one would be to "decorate" the returned value with a function that pretends to unwrap the types, but it actually doesn't. Example:

type UnwrapSetupValue<T> = T extends Ref<infer R> ? R : ShallowUnwrapRef<T>;const fakeUnwrap = <R>(setupOutput: R) => {  return setupOutput as UnwrapSetupValue<R>;};// ...@Componentexport default class MyClassComponent extends Vue {  @Setup setup() {    const { count, increment } = useCounter();    return fakeUnwrap({ count, increment });  }}

Now lets check it… And yay - it works!

But… does it look nice? And more importantly is it enough to fully use Composition API?

Refactor

My Personal list of issues that needs to be resolved to make it more complete and pleasant to work with is as follows:

  • Setup function actually gets two arguments: props and context we need to be able to use them to be able to fully use what Composition API gives us.
  • I don't like the idea of calling a method to access setup output. Especially if we add parameters to this method it will became pretty much unusable.
  • I don't like the idea of "decorating" return value of the setup. It would be nice to define body of setup function in exactly the same way as we would do this in a non-class component.

Solution: Use a getter instead of method (to skip () when accessing setup output) and “decorate” (not with a decorator but with a higher order function) a setup function instead of it’s return value.

Let me skip a detailed explanation of the differences and leave it as a challenge for the reader.

Final solution

Is it ideal? Probably not.

Does it work and allow to use Composition API with Options API in class components? Yes!

Keep in mind that this is only a temporary solution before you will rewrite the component entirely to the Composition API.

Implementation

import { Ref, SetupContext, ShallowUnwrapRef } from 'vue';import { createDecorator } from 'vue-class-component';export const Setup = createDecorator((options, key) => {  // get decorated getter definition  const getter = options.computed[key];  const getSetupFunction = typeof getter === 'function' ? getter : getter.get;  // set component `setup` option  options.setup = getSetupFunction();  // replace original getter with a getter to `this`  // to be able to access setup output  options.computed[key] = {    get() {      return this;    },  };});export type UnwrapSetupValue<T> = T extends Ref<infer R>  ? R  : ShallowUnwrapRef<T>;const setup = <R>(  setupFunction: (props: Record<string, unknown>, context: SetupContext) => R): UnwrapSetupValue<R> => {  // just fool Typescript and type output differently  // @Setup decorator will overwrite a getter  // and eventually the type will be correct  return setupFunction as UnwrapSetupValue<R>;};

Usage

Basic

import { Component, Vue } from 'vue-property-decorator';// ...@Componentexport default class MyClassComponent extends Vue {  @Setup get setupContext() {    return setup(() => {      const { count, increment } = useCounter();      return { count, increment };    });  }}

With props and context

import { Component, Prop, Vue } from 'vue-property-decorator';// ...@Componentexport default class NameInput extends Vue {  @Prop() initialValue: string;  @Setup get setupContext() {    return setup((props: Pick<NameInput, 'initialValue'>, context) => {      const nameModel = ref(props.initialValue);      const submit = () => context.emit('submit', name.value)      return {        nameModel,        submit,      };    });  }}

P.S. If you've made it this far and you're using Vue 3, or not using Vue class-style components make sure to check out this interesting video: https://youtu.be/kiXY7ynw6ek