Class Components with JSX in NuxtJS

July 10th 2020 NuxtJS Vue.js TypeScript

Although both Vue.js and NuxtJS have TypeScript support, it often seems incomplete. For example, there's no compile-time type checking in Vue.js templates. Any errors will only be reported at runtime. Currently, the only way to achieve compile-time type safety is to use render functions with JSX syntax instead.

Vue.js Templates

The component inputs (props in Vue.js terminology) can be formally described to some extent, but there's no way to specify the type of an object or the signature of a function. The following component uses Vue Property Decorator syntax for that:

@Component
export default class VueTextInput extends Vue {
  @Prop({ type: String, required: true }) readonly id!: string;
  @Prop({ type: String, required: true }) readonly label!: string;
  @Prop({ type: String, required: false, default: '' }) readonly value!: string;

  @Emit()
  private valueChanged(_newValue: string) {}

  private handleInput(event: InputEvent): void {
    this.valueChanged((event.target as HTMLInputElement).value);
  }
}

Still, the build will succeed if you omit a required prop because of a typo, for example. Only at runtime when the component is rendered, a warning will be printed to the browser console if you're using a development build:

[Vue warn]: Missing required prop: "id"

found in

---> <VueTextInput> at components/VueTextInput.vue
       <VuePage> at pages/vue.vue
         <Nuxt>
           <Layouts/default.vue> at layouts/default.vue
             <Root>

If you make a typo in the custom event name instead, you won't even get a warning in the browser console. Your event handler simply won't get called.

Hunting down these errors can be very time-consuming in a large application. If you for some reason change the props of a component when it's already used in many places, you will need to find and fix all these occurrences without any help of the compiler or other tools.

Similarly, if you make a typo in the name of a property that you want to bind to a prop, only a warning will be printed to the browser console in the development build:

[Vue warn]: Property or method "Id" is not defined on the instance but referenced during render. Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property. See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.

found in

---> <VuePage> at pages/vue.vue
       <Nuxt>
         <Layouts/default.vue> at layouts/default.vue
           <Root>

If you're using Vetur for Visual Studio Code, you can at least enable its experimental Template Interpolation Service feature:

{
  "vetur.experimental.templateInterpolationService": true
}

This will allow you to see the latter type of errors marked as such in the editor:

Error reported by Vetur's Template Interpolation Service

VTI and vue-type-check are command-line tools with similar functionality that can be included in the build process but I couldn't get either to work on my Windows machine.

Render Functions with JSX

You can achieve a much higher level of compile-time type safety if you decide to replace the templates with the render function and the JSX syntax. This approach, as described below, is highly inspired by Greg Solo's blog post which I stumbled upon while looking for a way to improve the TypeScript development experience with Vue.js.

An equivalent component to the one above would be implemented as follows:

export interface TsxTextInputProps {
  id: string;
  label: string;
  value?: string;
  valueChanged?: (value: string) => void;
}

@Component({
  props: {
    id: { type: String, required: true },
    label: { type: String, required: true },
    value: { type: String, required: false, default: '' },
    valueChanged: { type: Function, required: false },
  },
  render(this: TsxTextInput): Vue.VNode {
    return (
      <div>
        <label for={this.$props.id}>{this.$props.label}</label>
        <input
          id={this.$props.id}
          name={this.$props.id}
          type="text"
          value={this.$props.value}
          onInput={(event: InputEvent) => this.handleInput(event)}
        />
      </div>
    );
  },
})
export default class TsxTextInput extends VueComponent<TsxTextInputProps> {
  private handleInput(event: InputEvent): void {
    this.$props.valueChanged?.((event.target as HTMLInputElement).value);
  }
}

For this to work, the generic VueComponent class must also be added to the project:

export class VueComponent<P> extends Vue {
  $props!: P;
}

The following compiler option is required in tsconfig.json to enable JSX support in TypeScript:

{
  "compilerOptions": {
    "jsx": "preserve"
  }
}

To correctly handle the JSX types in Vue.js, the following shim must be added to a .d.ts file in the project:

declare global {
  namespace JSX {
    // tslint:disable no-empty-interface
    interface Element extends VNode {}
    // tslint:disable no-empty-interface
    interface ElementClass extends Vue {}
    interface IntrinsicElements {
      [elem: string]: any;
    }
    interface ElementAttributesProperty {
      $props: {};
    }
  }
}

Unfortunately, the props definitions are now duplicated. The interface is used by the TypeScript compiler to ensure type safety. And the standard props declaration is still required by Vue.js. You can also notice that I replaced the custom event with a callback prop. Unlike custom events, these are also type-checked at compile time.

At the price of a few more lines of code, the mistakes as in the examples above will now be detected at compile-time (and also directly in the code editor):

  • a mistyped prop name:

    Property 'Id' does not exist on type 'TsxTextInputProps'.

  • a mistyped callback prop name (as a replacement for a custom event):

    Property 'valueChange' does not exist on type 'TsxTextInputProps'.

  • a mistyped property name bound to a prop:

    Cannot find name 'Id'.

It's also worth mentioning that I found no way to make Vue.js Scoped CSS working with this approach. However, if you need component-scoped CSS in your application, you can still use CSS Modules.

In this case, you will need an accompanying .vue file for each component in which you will put the CSS:

<script src="./TsxTextInput.tsx"></script>

<style module>
  .input-label {
    color: red;
  }
</style>

The styles will be available to you in the $style property of the component:

<label class={this.$style['input-label']} for={this.$props.id}>
  {this.$props.label}
</label>

To make the TypeScript compiler aware of it, another shim is required in the project:

declare module 'vue/types/vue' {
  interface Vue {
    $style: { [key: string]: string };
  }
}

You can get a full working sample with Vue.js template-based components and JSX-based components in my repository on GitHub.

Although TypeScript support in Vue.js seems to be an afterthought, you can still get better compile-time checking in templates if you decide to use render functions with JSX instead of the more commonly used Vue.js templates.

There are downsides to it, though. Although JSX is officially supported in Vue.js, it won't necessarily work out-of-the-box in all frameworks and boilerplates. Additional configuration and workarounds will be required for that. And you'll find less resources online when searching for solutions. It's up to you to decide if you're willing to pay that price.

Let's all hope that TypeScript support will be better in Vue.js 3.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License