Migrating Off of PromiseProxyMixin in Ember Octane

An important refactor for getting rid of mixins and proxies.

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, Promise or 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'
    • isLoading
    • isError
    • value, which is either the resolved value if the promise has resolved or undefined if it’s still pending or has rejected
    • error, which is either the promise rejection value if the promise rejected or undefined if 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 .value intermediate value lookup: this.userData.value.userName instead of this.userData.userName. (This is the result of composing the data instead of inheriting it.)
  • The names of the state values are different: isLoaded and isError instead of isFulfilled and isRejected.

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

  1. Ember’s API guides for PromiseProxyMixin give an example very similar to this, but with less context and more jQuery. I’ve replaced the use of jQuery’s $.getJSON with fetch and Body.json(), and used arrow functions instead of function declarations; I’ve also embedded it in an example component to make the ideas a bit clearer. ↩︎