Getting started with Glimmer Components in Ember.js
There's a lot of buzz in the Ember community about the upcoming release of Ember Octane Edition.
It represents an enormous amount of work by the Ember core team and community.
The release will roll out a hugely simplified javascript model and the use of Glimmer components radically alters the way we need to write code.
I'll be using TypeScript for many of the code examples.
The source code is available on Github
A good place to play with Octane is via CodeSandbox using @nullvoxpopuli's starter kit
What's the difference between a 'Classic' Component and a Glimmer Component?
First major difference is that you use a different import
import Component from '@glimmer/component';
vs
import Component from '@ember/component';
The next thing that is very different is that you no longer have a 'wrapping' element provided.
With a Classic
component, you always have a wrapping div
(unless you set tagName: ''
). With a Glimmer
component, you only get what you explicitly set in your template.
You also get access to something referred to as splattributes
. Splattributes will take any attributes you supply to the component invocation and add them where you call ...attributes
. For more information take a look at the RFC
Given a template like this
<img ...attributes>
</img>
and an invocation like this
<SafeImage src="https://www.example.com/image1.png">
Then end result would be
<img src="https://www.example.com/image1.png">
We'll look at more sophisticated usage of this as we go on.
The next major difference is the ability to use @tracked
. This decorator is like magic.
It allows us to 'just' use properties and assign value to them. No more this.get
and this.set
.
For example
import Component from '@glimmer/component';
import {tracked} from '@glimmer/tracking';
import { action } from '@ember/object';
export default class Counter extends Component {
@tracked count: number = 0;
get formattedCount() {
return this.count.toString().padStart(3, '0');
}
@action
increment() {
this.count++
}
@action
decrement() {
this.count--
}
}
<span>{{this.formattedCount}}</span>
<button {{on "click" this.increment}}>Increment</button>
<button {{on "click" this.decrement}}>Decrement</button>
Clicking either button will mutate the count
and the formattedCount
will automatically be updated and the DOM will reflect that change.
The 'on' Modifier
Where you may have done these in the past
<button {{action 'someAction'}}></button>
<button onclick={{action 'someAction'}}></button>
The recommended way going forward is the on
modifier. It is used like this
<button {{on 'click' this.doStuff}}></button>
With a backing class such as
import Component from '@glimmer/component';
export default class Counter extends Component {
doStuff(event: MouseEvent) {
// We do not have access to `this`
}
}
doStuff
will be invoked when the button is clicked and will have the event
as the first argument.
It's important to note that with the code about, we do not have access to the actual component as this
.
If we want to access this
, we need to use the action
decorator
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class Counter extends Component {
@action
doStuff(event: MouseEvent) {
// We now have access to `this`
}
}
Expanding on a SafeImage Component
With this new knowledge, we can easily build a component that can deal with issues where an image may fail to load or may even not be present. In that case, maybe we want to show some kind of placeholder image.
<img
...attributes
{{on "error" this.fallbackImage }}
>
So we apply the ...attributes
and register a handler for onerror
import Component from '@glimmer/component';
interface SafeImageArgs {}
export default class SafeImage extends Component<SafeImageArgs> {
fallbackImage(event: ErrorEvent) {
const target = event.target as HTMLImageElement;
target.src = "https://picsum.photos/200/300";
}
}
In the event that an error occurs, we will now load the placeholder image.
What's really neat is that with the ...attributes
we can pass any attributes to the image component we like.
So
<SafeImage src="https://example.com/foo.png"
alt="Avatar image"
class="avatar"
height="50"
width="50"
/>
would result in
<img
src="https://picsum.photos/200/300"
alt="Avatar image"
class="avatar"
height="50"
width="50">
This makes for super flexible components and gets rid of the need to be super verbose with boundAttributes = ['alt', 'src', 'height', 'width']
!
Class attribute is a bit special
One thing that is worth nothing is that if you already have a class
attribute in your template, the passed values will be merged.
<img class="AlwaysApplied" ...attributes>
<SafeImage src="https://example.com/foo.png"
alt="Avatar image"
class="avatar"
height="50"
width="50"
/>
Would result in
<img
src="https://picsum.photos/200/300"
alt="Avatar image"
class="AlwaysApplied avatar"
height="50"
width="50">
Note that AlwaysApplied
is merged with the passed class.
Arguments and mutability
We haven't yet looked at passing values to our components to be used in the JS or the template.
Glimmer components, unlike classic components do not merge all the passed arguments with the base class, instead they are collected on a namespace args
and must be passed with the @
prefix.
When accessing this.args
items are immutable (mostly, sort of ...)
Given a Mutator
component we are going to pass two items, a string and an object.
// within class body
plainValue = 'Hello';
mutableObject = {
message: 'Goodbye'
}
array = [];
<Mutator
@plainValue={{this.plainValue}}
@mutableObject={{this.mutableObject}}
@array={{this.array}}
/>
The backing template looks like this.
<button {{on "click" this.tryImmutable}}>
Cannot mutate
{{@plainValue}}
</button>
<button {{on "click" this.tryMutable}}>
Can mutate interior ->
{{@mutableObject.message}}
</button>
<button {{on "click" this.pushToArray}}>
Push to array {{#each @array as |item|}}{{item}}, {{/each}}
</button>
and the JS / TS looks like this
import Component from '@glimmer/component';
import { action, set } from '@ember/object';
interface MutatorArgs {
plainValue: string;
mutableObject: {
message: string;
};
array: any[]
}
export default class Mutator extends Component<MutatorArgs> {
@action
tryImmutable() {
set(this.args, 'plainValue', 'Goodbye');
}
@action
tryMutable() {
set(this.args.mutableObject, 'message', 'Goodbye');
}
@action
pushArray() {
this.args.array.pushObject('Goodbye');
}
}
What's that funky interface
stuff about?
if we hit the Cannot Mutate
button, we trigger the tryImmutable
method which is going to try and reassign plainValue
on this.args
. We will see an error that looks like this in our console
You attempted to set #plainValue on a components arguments. Component arguments are immutable and cannot be updated directly, they always represent the values that are passed to your component. If you want to set default values, you should use a getter instead
Basically, you are not allowed to reassign a value on args
.
If we are, however dealing with an object (or an array, which of course in JS is just an object), then we do have the ability to make changes.
Hitting Can mutate interior -> Hello
will change it to Can mutate interior -> Goodbye
, whilst hitting Push to array
will start adding the string 'Goodbye'. Note that I used pushObject
, because using push
would update the contents of the array, but it would not trigger updates of the DOM as Ember would not know to recompute.
Just because you can doesn't mean you should
Now that I've shared how it's possible to 'overcome' the immutability of args
, I want to say that you should really think about whether that is the right thing to do.
Like many scenarios in software development, just because you can, doesn't mean you should. In most scenarios it's better to pass down an action and use that to update the value
One area I think it's fairly reasonable to do so is to two way bind an <input>
, though you don't need to jump through so many hoops there.
With that scenario, you may do this
<MyForm @firstName={{this.data.firstName}}>
And in your form template
<Input @value={{@firstName}}>
And you now have two way binding. This is still a bit of a contentious issue in the community, so I'm sharing information only, not judging you!
Interfaces
One super nice feature of Typescript and Glimmer components is that you can add types via an interface.
From our example earlier.
interface MutatorArgs {
plainValue: string;
mutableObject: {
message: string;
};
array: any[]
}
export default class Mutator extends Component<MutatorArgs>
Now if we try do something funky like this.plainValue.toExponential(10)
, our compiler can yell at us because a string
doesn't support that method.