Converting an Ember Component to Native Class Syntax

Learn how to refactor an Ember component to use Native ES classes – without rewriting it into a Glimmer component.

Summary

In this video, we'll continue looking at converting original ember objects into native classes, this time with a component. We'll be using the ember-native-class-codemod as we did in the previous video, and I would recommend taking a look at that for a walk-through of how to set it up.

I'm going to be leaning heavily on ember-cli/eslint-plugin-ember and ember-cli-template-lint today, and I highly recommend these as helpful tools in everyday development as well as in this conversion process in particular.

NOTE: This conversion is NOT the same as converting your component to a glimmer component. Converting your legacy ember class components to use native javascript classes is the first (but not final) step in upgrading your app to use Glimmer and Octane.

To start, we have our component which is fairly simple, it fetches some comments on insert, it's got a tagName, and toggles more comments into view if there are more than 5.

Original file:

import Component from '@ember/component';
import {task} from 'ember-concurrency';
import {inject as service} from '@ember/service';
import {computed} from '@ember/object';
import {readOnly, gt} from '@ember/object/computed';

export default Component.extend({
  tagName: 'section',
  store: service(),

  post: null,
  isShowMoreExpanded: false,

  init() {
    this._super(...arguments);
    this.set('comments', []);
  },

  commentCount: readOnly('comments.length'),

  isTooManyComments: gt('commentCount', 5),

  shouldShowExpandCommentOption: computed('isTooManyComments', 'isShowMoreExpanded', function () {
    if (this.isTooManyComments && !this.isShowMoreExpanded) return true;
    return false;
  }),

  slicedComments: computed('isTooManyComments', 'isShowMoreExpanded', function () {
    if (this.isTooManyComments && !this.isShowMoreExpanded) {
      return this.comments.slice(0, 5);
    } else {
      return this.comments;
    }
  }),

  actions: {
    expandComments() {
      this.set('isShowMoreExpanded', true);
    },
  },

  didInsertElement() {
    this.fetchComments.perform(this.get('post'));
  },

  fetchComments: task(function* (post) {
    const comments = yield this.get('store').query('comment', {
      post_id: post.id,
      include: 'author',
    });
    this.set('comments', comments);
  }),
});

We can run the codemod on our component:

> npx ember-native-class-codemod http://localhost:4200 app/components/comments.js

After the codemod is finished, we see similar updates as last time and additionally:

  • The tagName property has become a class decorator,
  • Service injection has become the service decorator,
  • Our initialization of post has changed from colon to equal (also we've lost all our commas),
  • Syntax of super has changed from this._super to super.init (the name of the method we're calling super from),
  • Computed property macros are decorators,
  • Actions hash is gone, and actions are no longer grouped, just marked with the @action decorator,
  • The ember-concurrency task has become marked with a decorator.

After codemod:

import classic from 'ember-classic-decorator';
import { tagName } from '@ember-decorators/component';
import { action, computed } from '@ember/object';
import { inject as service } from '@ember/service';
import { gt, readOnly } from '@ember/object/computed';
import Component from '@ember/component';
import {task} from 'ember-concurrency';

@classic
@tagName('section')
export default class Comments extends Component {
  @service
  store;

  post = null;
  isShowMoreExpanded = false;

  init() {
    super.init(...arguments);
    this.set('comments', []);
  }

  @readOnly('comments.length')
  commentCount;

  @gt('commentCount', 5)
  isTooManyComments;

  @computed('isTooManyComments', 'isShowMoreExpanded')
  get shouldShowExpandCommentOption() {
    if (this.isTooManyComments && !this.isShowMoreExpanded) return true;
    return false;
  }

  @computed('isTooManyComments', 'isShowMoreExpanded')
  get slicedComments() {
    if (this.isTooManyComments && !this.isShowMoreExpanded) {
      return this.comments.slice(0, 5);
    } else {
      return this.comments;
    }
  }

  @action
  expandComments() {
    this.set('isShowMoreExpanded', true);
  }

  didInsertElement() {
    this.fetchComments.perform(this.get('post'));
  }

  @task(function* (post) {
    const comments = yield this.get('store').query('comment', {
      post_id: post.id,
      include: 'author',
    });
    this.set('comments', comments);
  })
  fetchComments;
}

For this component, let's take it a step further, and remove the classic decorator.

First, init is a component lifecycle hook that is no longer supported with component native classes. Luckily, native classes have constructor which will work with almost all uses of init. We can change our init method to a constructor, which will, in-turn, require us to update our super invocation. super, in a constructor, appears alone and must be called before this is referenced in the method. Another thing we must update is this.get and this.set. Since we are using a native class, the scope of this is different and no longer has get and set methods. We'll need to update instances of this.get and this.set and set the property on this itself. If you have already used es5-getter-ember-codemod on your app, which I highly recommend, you'll not need to go through this.

If we look at our page in the browser again, we see an error.

Uncaught (in promise) Error: Assertion Failed: You attempted to update .comments to "", but it is being tracked by a tracking context, such as a template, computed property, or observer. In order to make sure the context updates properly, you must invalidate the property when updating it. You can mark the property as `@tracked`, or use `@ember/object#set` to do this.

This is telling us that the comments property needs to be tracked. We can import tracked from glimmer and tell this component we want properties watching comments to be updated when comments changes. When we mark a property as tracked, any computed property or getter that uses that property will automatically be recalculated when the prop changes. This means that we do not need to mark computed properties dependent on properties within them -- they already know when to change.

With that update, we can see the page loads as expected and we have a fully native ember component class!

Final version:

import {tagName} from '@ember-decorators/component';
import {action, computed} from '@ember/object';
import {inject as service} from '@ember/service';
import {gt, readOnly} from '@ember/object/computed';
import Component from '@ember/component';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';

@tagName('section')
export default class Comments extends Component {
  @service
  store;

  @tracked post = null;
  @tracked isShowMoreExpanded = false;
  @tracked comments = [];

  @readOnly('comments.length')
  commentCount;

  @gt('commentCount', 5)
  isTooManyComments;

  @computed('isTooManyComments', 'isShowMoreExpanded')
  get shouldShowExpandCommentOption() {
    if (this.isTooManyComments && !this.isShowMoreExpanded) return true;
    return false;
  }

  @computed('isTooManyComments', 'isShowMoreExpanded')
  get slicedComments() {
    if (this.isTooManyComments && !this.isShowMoreExpanded) {
      return this.comments.slice(0, 5);
    } else {
      return this.comments;
    }
  }

  @action
  expandComments() {
    this.isShowMoreExpanded = true;
  }

  didInsertElement() {
    this.fetchComments.perform(this.post);
  }

  @task(function* (post) {
    const comments = yield this.store.query('comment', {
      post_id: post.id,
      include: 'author',
    });
    this.comments = comments;
  })
  fetchComments;
}

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.