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
tosuper.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;
}