Assumed audience: Software developers working with Ember Octane.
Idiomatic Ember Octane avoids using Ember’s classic Mixin and ObjectProxy types. However, a very common pattern in many Ember Classic apps and addons was to use Ember’s PromiseProxyObject mixin in conjunction with ObjectProxy to expose the state of a promise to end users, and to make accessing the resolved data more convenient. Migrating an app from Ember Classic to be idiomatic Ember Octane means replacing all of that with something more Octane-friendly.
In this post, we will cover how to rewrite code that uses promise proxy mixing into a lightweight, auto-tracked, Octane-ready, future-friendly solution — and hint at a new to think about asynchronous data in a new way, as well!
A direct migration
We’ll start with a utility that allows us to create a proxy at will, createPromiseProxy:
// my-app/utils/object-promise-proxy.js
import ObjectProxy from '@ember/object/proxy';
import PromiseProxyMixin from '@ember/object/promise-proxy-mixin';
const ObjectPromiseProxy = ObjectProxy.extend(PromiseProxyMixin);
export function createPromiseProxy(promise) {
return ObjectPromiseProxy.create({ promise });
}
Then we might use it in a component that looks like this:1
import Component from '@glimmer/component';
import { createPromiseProxy } from 'my-app/utils/object-promise-proxy.js';
const USERS_API = 'example.com/users';
export default class SmartComponent extends Component {
get userData() {
let url = new URL(USERS_API);
url.searchParams.append('id', this.args.id);
return createPromiseProxy(fetch(url)).then((data) => data.json());
}
}
Here we’re relying on the fact that args are auto-tracked: this getter consumes this.args.id, so it’ll rerun any time the component is invoked with a new id. In a classic Ember component, you might see @computed('id') to update whenever the id argument updated.
We would invoke the component something like this (presumably with a more dynamic source of the ID):
<SmartComponent @id={{1234}} />
The body of the component might look like this:
{{#if this.userData.isFulfilled}}
{{this.userData.userName}}
{{else if this.userData.isRejected}}
Whoops, something went wrong!
{{/if}}
To migrate away from this, we can use a composition-based approach instead of a mixin/inheritance-based approach. I’m going to use a load helper and associated AsyncData structure (defined here). I plan to write a post explaining the underlying ideas for that helper in the future. For now, it’s enough to know the following things:
-
The helper can be used with any value,
Promiseor not. -
It can be used in templates or imported and used in JavaScript.
-
It returns an
AsyncData, which has the following public properties:state, which can be'LOADING','LOADED', or'ERROR'isLoadingisErrorvalue, which is either the resolved value if the promise has resolved orundefinedif it’s still pending or has rejectederror, which is either the promise rejection value if the promise rejected orundefinedif the promise is still pending or has resolved successfully
Using it looks pretty similar to using the component with the promise proxy mixin — we’ve just replaced the createPromiseProxy call with the load call:
import Component from '@glimmer/component';
import { load } from 'my-app/helpers/load';
const USERS_API = 'http://www.example.com/users';
export default class SmartComponent extends Component {
get userData() {
let url = new URL(USERS_API);
url.searchParams.append('id', this.args.id);
return load(fetch(url)).then((data) => data.json());
}
}
Invoking it would be identical; the only change is in the corresponding template:
{{#if this.userData.isLoaded}}
{{this.userData.value.userName}}
{{else if this.userData.isError}}
Whoops, something went wrong!
{{/if}}
The actual changes here are small:
- There’s one extra
.valueintermediate value lookup:this.userData.value.userNameinstead ofthis.userData.userName. (This is the result of composing the data instead of inheriting it.) - The names of the state values are different:
isLoadedandisErrorinstead ofisFulfilledandisRejected.
And with that, we’ve successfully gotten away from PromiseProxyMixin in our app code!
Alternative: less JS, more template
We could also do more of this template-side, since the load tool is both a utility function and a helper. In that case, here’s how the component would look:
import Component from '@glimmer/component';
const USERS_API = 'http://www.example.com/users';
export default class SmartComponent extends Component {
get userData() {
let url = new URL(USERS_API);
url.searchParams.append('id', this.args.id);
return fetch(url);
}
}
The template would use the load helper with the resulting promise in the template, by invoking it with the let helper:
{{#let (load this.userData) as |result|}}
{{#if result.isLoaded}}
{{result.value.userName}}
{{else if result.isError}}
Whoops, something went wrong!
{{/if}}
{{/let}}
Alternative: less template, more JS
Because the load utility and its AsyncData type use autotracking, we can freely do things with the resulting data type in our JavaScript, too. For example, if we wanted to pull all the logic into a new component which just accepts an AsyncData for the user profile, we could do that. Assume we had our original load-using component version, which has this.userData as an AsyncData. We could pass it to another component like so:
<RenderUser @userData={{this.userData}} />
Then we could make the RenderUser component’s template be extremely simple:
<div>{{this.content}}</div>
The content could be specified via a getter on the backing class:
import Component from '@glimmer/component';
export default class RenderUser extends Component {
get content() {
switch (this.args.userData.state) {
case 'LOADED': {
let user = this.args.userData.value;
return `${user.name} is ${user.age} years old!`;
}
case 'LOADING':
return 'Loading...';
case 'ERROR':
default:
return 'Something went wrong. 😱 Please try again!';
}
}
}
Again, we’re taking advantage of args being auto-tracked: if we ever got a different AsyncData passed in as userData, we would update to the correct version of that. Likewise, because the state and data properties of the AsyncData type are tracked, this getter will recompute any time either of those is updated as well.
Summary
We do have to type .value in a couple of places now… but in exchange, we get all the benefits of the old PromiseProxyMixin in exchange, and we get to get rid of a Mixin and a use of Ember’s classic (and very expensive for performance) ObjectProxy, which is yet another Mixin. What’s more, there’s no magic here. You can implement load yourself in plain JavaScript using the Glimmer tracking library, just the same as I did!
Feel free to respond with questions or comments on Ember Discuss. And if you’re curious about how load and AsyncData work, check out the follow-up post!
Notes
Ember’s API guides for
PromiseProxyMixingive an example very similar to this, but with less context and more jQuery. I’ve replaced the use of jQuery’s$.getJSONwithfetchandBody.json(), and used arrow functions instead offunctiondeclarations; I’ve also embedded it in an example component to make the ideas a bit clearer. ↩︎