Site editor

Let's build a Data Loader component

What would it look like if we could load secondary data entirely from our templates?

Summary

Today we're going to write a component that lets callers make generic backend queries entirely from a template:

<LoadRecords
  @modelName='podcast-episode'
  @params={{hash
    sort='-position'
    page=(hash limit=4)
  }}
as |data|>

  {{#each data as |episode|}}
    ...
  {{/each}}

</LoadRecords>

We'll use the Recent Episodes section of EmberMap's podcast page as our use case. Let's start by looking at the current implementation:

{{! podcast/episode/template.hbs }}

{{!-- rest of template --}}

{{ui-hooks did-insert=(perform loadLatestEpisodes)}}

{{#ui-title}}
  Recent episodes
{{/ui-title}}

{{#if latestEpisodes}}
  {{#each latestEpisodes as |episode|}}
    ...
  {{/each}}
{{/if}}

We're using {{ui-hooks}} to kick off the loadLatestEpisodes task, which fetches some data and sets the latestEpisodes instance property:

// podcast/episode/controller.js
export default Controller.extend({

  loadLatestEpisodes: task(function*() {
    let episodes = yield this.store.loadRecords('podcast-episode', {
      sort: '-position',
      page: {
        limit: '4'
      }
    });

    this.set('latestEpisodes', episodes);
  })

});

This task is essentially a passthrough to Ember Data's APIs. If we could write a Data Loader component, we could eliminate the need for our use of the {{ui-hooks}} component, the task, and thus even the controller file altogether.

The Data Loader could be used like this:

- {{ui-hooks did-insert=(perform loadLatestEpisodes)}}

+ <LoadRecords
+   @modelName='podcast-episode'
+   @params={{hash
+     sort='-position'
+     page=(hash limit=4)
+   }}
+ as |latestEpisodes|>

    {{#ui-title}}
      Recent episodes
    {{/ui-title}}

    {{#if latestEpisodes}}
      {{#each latestEpisodes as |episode|}}
        ...
      {{/each}}
    {{/if}}

+ </LoadRecords>

Let's implement it.

We'll generate a LoadRecords component and make it tagless:

// components/load-records/component.js
export default Component.extend({

  tagName: ''

});

Now we want to translate the Ember Data query into a generic query task on this component, and kick it off on didInsertElement:

// components/load-records/component.js
export default Component.extend({

  tagName: '',

  store: service(),

  didInsertElement() {
    this._super(...arguments);

    this.query.perform();
  },

  query: task(function*() {
    return yield this.store.loadRecords(this.modelName, this.params);
  })

});

Looking at our app, our Data Loader is working so far - we see the data request in the console. All that's left is to yield the result of the task:

{{! components/load-records/template.hbs }}
{{yield this.query.last.value}}

Now we see the data rendered on the page again! The component is working great so far.


We can expand the API a bit to allow callers to specify more Ember Data options. For example, in our case we know this query changes infrequently enough that it would be nice to disable background reloading. Let's add that option:

  <LoadRecords
    @modelName='podcast-episode'
    @params={{hash
      sort='-position'
      page=(hash limit=4)
    }}
+   @backgroundReload={{false}}
  as |latestEpisodes|>

    ...

  </LoadRecords>
  query: task(function*() {
-   return yield this.store.loadRecords(this.modelName, this.params);
+   let params = { ...this.params };

+   if (this.backgroundReload !== undefined) {
+     params.backgroundReload = this.backgroundReload;
+   }

+   return yield this.store.loadRecords(this.modelName, params);
  })

Now our Data Loader only makes this query once, and even as this page is torn down and re-rendered, the query is served from cache.

Our template has a high-level declarative query, and our caching layer continues to do its job outside of the rendering lifecycle of our component. Powerful stuff!

Before we wrap up, let's add loading and error states to our Data Loader.

We'll yield them out as the first two arguments, so they're front and center in the developer's mind:

  <LoadRecords
    @modelName='podcast-episode'
    @params={{hash
      sort='-position'
      page=(hash limit=4)
    }}
    @backgroundReload={{false}}
- as |latestEpisodes|>
+ as |isLoading isError latestEpisodes|>

    ...

  </LoadRecords>

To implement this, we just need to update our <LoadRecords> template to yield out some more derived state from its Ember Concurrency task:

  {{! components/load-records/template.hbs }}
- {{yield this.query.last.value}}
+ {{yield
+   this.query.isRunning
+   this.query.last.error
+   this.query.last.value
+ }}

Now callers have full control over how their UI renders during the different stages of a data request!


Constraining our data queries to the template gives us a concise, declarative syntax for fetching data. It also gives us all the guarantees that come from the principles of declarative rendering: if we fully express our data needs in a template, yield out the state of that query, and ensure our UI is a pure function of that state, we can rest assured that Ember will always keep our app in sync even as this data changes over time.

Our Data Loader is simple, but this is only the beginning. Within just a few minutes we were able to write a powerful component that eliminated indirection in our code. Going forward, we now have fewer decisions to make when it comes to fetching data outside of the route.

View the full component here.

👋 Hey there, Ember dev!

We hope you enjoyed this free video 🙂

If you like it and want to keep learning with us, we've written a free 6-lesson email course about the fundamental patterns of modern component design in Ember.

To get the first lesson now, enter your best email address below:

You can also check out more details about the course by clicking here.

Questions?

us, or ask in #media on Discord