This is a really common challenge people have as they learn Octane idioms and Glimmer Components, so I’m glad you asked it!
The key here is moving away from theimperative mode we tended to reach for in Ember Classic, where we would respond to a lifecycle hook or event by telling the app “create some new state” or “update in this way” and moving to a more truly one-way data flow paradigm. You can think of this as really going all in on Data Down, Actions Up. That means we need to transform your approach with did-insert
into something that defines everything in terms of data.
To get there, we need to define what the template renders purely in terms of data derived from the arguments to the component. The result might look something like this:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
const PLACEHOLDER_URL = '...';
const FAILED_URL = '...';
class ImageData {
@tracked state = 'loading';
@tracked value;
@tracked reason;
}
export default class Image extends Component {
get imageData() {
let data = new ImageData();
let { url } = this.args.photo;
fetch(url)
.then(() => {
data.state = 'loaded';
data.value = url;
})
.catch((reason) => {
data.state = 'error';
data.reason = reason;
});
return data;
}
get src() {
switch (this.imageData.state) {
case 'loaded':
return this.imageData.value;
case 'loading':
return PLACEHOLDER_URL;
case 'error':
return FAILED_URL;
}
}
}
Here’s the corresponding template (yours might be more complicated, of course):
<img src={{this.src}} alt={{@photo.alt}} />
Note that in src
, we use imageData
to determine what to show, and imageData
uses args
to determine what to load. Since we’re using src
in our template, any time the photo
argument to Image
changes (or, if it has internal tracked properties for src
and alt
and either of those change) it’ll update correctly!
(If it’s surprising that we’re doing an async data lookup in a getter, which is often treated as an antipattern, see this discussion on “Guidance re: triggering tasks from component args”.)
This pattern comes up enough that my team actually built a load
helper and AsyncData
type to make it convenient, which can be used on either the JS or Handlebars side. Using that, this would be the whole implementation of the backing class:
import Component from '@glimmer/component';
import { load } from 'my-app/helpers/load';
const PLACEHOLDER_URL = '...';
const FAILED_URL = '...';
export default class Image extends Component {
get imageData() {
let { url } = this.args.photo;
let promise = fetch(url).then(() => url);
return load(promise);
}
get src() {
switch (this.imageData.state) {
case 'loaded':
return this.imageData.value;
case 'loading':
return PLACEHOLDER_URL;
case 'error':
return FAILED_URL;
}
}
}
The template would be exactly the same!
The big takeaways here:
- There’s no need for
did-insert
in the template anymore! All the template needs to know is what src
to use for the img
tag, and that’s just computed as data by the class.
- We now have 100% 1-way data flow. Arguments come into the component, and it updates automatically just by using auto-tracking. (You would get the same effect from VDOM implementations in other frameworks, but the performance tradeoffs would be different.)
- Everything in this approach—including the async operation!—is just data. We’ve managed to describe the whole component flow in terms of one source of truth (or “root state”): the
photo
argument to the component. The two other pieces of data are derived from that source of truth (so they’re “derived state”).
If you’re curious about the load
helper and AsyncData
type, I posted an example this week of using it to migrate away from PromiseProxyMixin
, I’ll be writing up an explanation of the philosophy behind it—which is basically just taking the ideas from this example and expanding them into something more general-purpose—this coming work week, and I’ll post a link to that on this forum when it’s live!