Ember-data: Turn Frustration š¤¬ into Celebration š
Ember-data is a powerful tool that allows ember.js developers to manage API calls and deal with the state on the front-end side. For inexperienced users, it can also be a source of great frustration. In this article, we will build a simple web app that uses ember-data to fetch and display back-end data and address some of the issues that we will encounter along the way.
Gotta catch `em all š
Youāre assigned a task to create an app that retrieves data from pokeapi.co and displays it for the user. Your only requirements are to use ember.js and ember-data. Sounds simple, right?
I have created a GitHub repository called poke-app if youād like to follow along https://github.com/thisFunction/poke-app, and you can see the app in action on Heroku.
Getting started š£
Before we start coding, letās write down some requirements for this project.
Our app will have two routes:
- pokemon route: this route will show a total count of PokƩmon available on the API, and list PokƩmon names in groups of 20. This information will come from GET requests to https://pokeapi.co/api/v2/pokemon?limit=20&offset={offset}. We will use the offset query parameter to account for paginated data.
- pokemon-details route: this route will display a specific PokĆ©monās name, height, weight, and image. This information will come from GET requests to https://pokeapi.co/api/v2/pokemon/{id}.
On your marks, get set, go! š“
Weāll start by generating our first route called pokemon:
ember generate route pokemon
In your router.js
file, you should see the following code:
Router.map(function() {
this.route('pokemon');
});
Next, we will create the second, pokemon-details, route. This will be a nested route, and its path will be pokemon/pokemon-details.
ember g route pokemon/pokemon-details
Your router.js
file will now look like this:
Router.map(function() {
this.route('pokemon', function() {
this.route('pokemon-details');
});
});
Because the pokemon-details route will have a dynamic URL, we need to add a path option, {path: '/pokemon_id}
, in our router.js
file. Itās also good practice for our URLs to reflect the RESTful API resource naming conventions, so thatās why weāll omit /pokemon-details/
in our URL and use slash instead.
Here is the whole route defined in our router.js
file:
Router.map(function() {
this.route('pokemon' , function() {
this.route('pokemon-details', { path: '/:pokemon_id' });
});
});
With these changes, our nested route is now able to display different information based on a PokĆ©monās id. š¤©
So far soā¦ š
If we open up our app and navigate to http://localhost:4200/pokemon or http://localhost:4200/pokemon/2, we see that our app renders our empty route templates, but in our console, we see this error:
message: "No model was found for 'pokemon'"
Since we added a dynamic path /:pokemon_id
to our pokemon-details route, ember-data will look for a model containing this id to generate the desired route.
Ember CLI to the rescue! š We need to generate two models. Letās do this in one shot:
ember g model pokemon && ember g model pokemon-detail
In our models
folder, we get two new files: pokemon.js
and pokemon-detail.js
. In these files, we need to add all the properties that ember-data will receive from the PokeAPI server so we can display them in our app.
In our pokemon route, we are interested in the total PokĆ©mon count, as well as the results array. You will notice that the PokeAPI also provides us with a next and previous link, but we will implement our own solution for those. ā”
In our models/pokemon.js
file, we need to add the following code:
import DS from 'ember-data';export default DS.Model.extend({
count: DS.attr(),
results: DS.attr(),
});
In our pokemon-details route, we are interested in displaying a specific Pokemonās name, height, weight, and image. So we need to make changes to our models/pokemon-detail.js
file and include those four properties:
import DS from 'ember-data';export default DS.Model.extend({
name: DS.attr(),
height: DS.attr(),
weight: DS.attr(),
sprites: DS.attr()
});
Adapters: WTF!? šµ
When we navigate to our routes again, we getā¦ more errors! š«
Our pokemon route seems OK, although it doesnāt render any data yet, pokemon-details throws this error message in our console.
Error while processing route: pokemon.pokemon-details
Ember Data Request GET /pokemons/2 returned a 404 Payload
If you take a peek at your developer tools network tab, you will see that there was a GET request made to http://localhost:4200/pokemons/2. Wow! Thatās pretty cool! š Now, if there only was a way to tweak that request URL and point it in the right directionā¦
Ember CLI to the rescue! šŖ Letās generate a pokemon adapter to help ember-data request information from the correct place.
ember generate adapter pokemon
In your adapters
folder, you will see a new file called pokemon.js
. In this file, we need to make a few changes. First of all, ember-data assumes that our back-end uses the JSON:API standard. When you read the first sentence on PokeAPI page, however, you will know that it is a RESTful API. Whenever youāre not sure what standard your API is written in, can just take an example response from the back-end and use a JSON:API validator to help you out.
Now, in the adapters/pokemon.js
file, we need to change this code:
export default DS.JSONAPIAdapter.extend({
});
with this:
export default DS.RESTAdapter.extend({
});
Next, we need to make sure that ember-data knows where to make the GET request. For this, we will use the host and namespace properties. These values come directly from the PokeAPI documentation.
export default DS.RESTAdapter.extend({
host: 'https://pokeapi.co',
namespace: 'api/v2'
});
Almost there š
If we refresh our route, we see that the request was made to https://pokeapi.co/api/v2/pokemons/2. By default, ember-data pluralizes the typeās name, so pokemon becomes pokemons. š¤ No worries, letās just add the pathForType method in our adapter, and return our model name as we have defined it, i.e. āpokemonā.
Our entire file now looks like this:
import DS from 'ember-data';export default DS.RESTAdapter.extend({
host: 'https://pokeapi.co',
namespace: 'api/v2',
pathForType(modelName) {
return modelName;
}
});
We refresh our page, and ā¦ TA-DA! In our network tab, we get a happy 200 status response, along with a shiny new payload. š¤ Thanks, ember-data! That was pretty cool, but letās see more ember-data magic! š§š½
Fetching all the data š
We started working on our API calls starting with the pokemon-details route. Now letās tie it all together, and work on the pokemon route.
For this, we need to give the pokemon route a model to display. Letās add a model method in our routes/pokemon.js
file. Here we will use the queryRecord to fetch a record from the API and include the offset parameter to help us with pagination.
import Route from '@ember/routing/route';export default Route.extend({
queryParams: { offset: { refreshModel: true }},
model(params) {
const offset = params.offset ? params.offset : 0;
return this.store.queryRecord('pokemon', {
offset
});
}
});
If we look in the developer tools network tab, we see that we get a payload and a happy 200 status from the server. š Time to use that data in our templates.
Displaying a list of Pokemon š»
If we inspect the payload that we receive from PokeAPI in our pokemon route, you will see that it is a flat structure JSON. It looks like this:
{
"count": 964,
"next": "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
"previous": null,
"results": [
{
"name": "bulbasaur",
"url": "https://pokeapi.co/api/v2/pokemon/1/"
} ā¦
]
}
Ember-data, however, expects our data to be structured a certain way. In fact, it uses JSON:API underneath to make sense of all the data it receives.
Serializers to the rescue! š
This is where serializers come into play. In a gist, serializers allow us to format, or ānormalizeā, the payload we receive from the back-end to reflect the structure ember-data expects.
First, letās generate a pokemon serializer:
ember g serializer pokemon
In our pokemon serializer, we will normalize the JSON payload so that all the properties we receive from the back-end are nested inside a pokemon object.
Basically, we need the data to look like this:
{
"pokeomon": {
"count": 964,
"next": "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
"previous": null,
"results": [
{
"name": "bulbasaur",
"url": "https://pokeapi.co/api/v2/pokemon/1/"
} ā¦
]
}
}
In our serializers/pokemon.js
file, we need to change DS.JSONAPISerializer
to DS.RESTserializer
. Then, weāll use the normalizeResponse
method to structure our payload. Also, since we used the queryRecord
method to fetch data from the API in our pokemon route, ember-data expects an id with each payload. A simple solution that weāll use here is Date.now()
. In production code, I would use the offset parameter value, but thatās a bit out of scope for this article. š
Letās have a look at the code:
import DS from 'ember-data';export default DS.RESTSerializer.extend({
normalizeResponse(store, model, payload, id, requestType) {
const payloadId = Date.now();
payload = {
pokemon: {
id: payloadId,
results: payload.results,
count: payload.count
}
}
return this._super(store, model, payload, id, requestType);
}
});
Basically, we have one main level pokemon property, which is also our model, and everything else nested inside will be used by that model, just like we defined it in our models/pokemon.js
file. š¤
Back to templates šš¾
Rendering our app in the browser, we see that all errors are gone form the console, and if you have ember-inspector installed, you can check that ember-data has the correct data for our pokemon model type!
To render this on our template, we need to use the model property and loop over the results array.
<h1>Pokemon</h1><p>Pokemon on PokeAPI:</p>
{{model.count}}<p>Results:</p>
<ul>
{{#each model.results as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
Next, we will use the ember template link-to
helper to redirect the app to the pokemon-detail pages. But first, we need to extract the pokemon id from the model.results.url
property. There are many ways to go about this, one way is to create a helper to help us out. š»
ember g helper extract-id-from-url
In the helper file, helpers/extract-id-from-url.js
we will use the following code:
import { helper } from '@ember/component/helper';export function extractIdFromUrl(params) {
const [url] = params;
let pokemonId = url.replace('https://pokeapi.co/api/v2/pokemon/','').replace('/','');
return pokemonId;
}
export default helper(extractIdFromUrl);
In our templates/pokemon.hbs
file, we also need to add a link-to
helper with the full route path separated with periods, followed by the extracted id:
<li>{{#link-to "pokemon.pokemon-details" (extract-id-from-url result.url)}}{{result.name}}{{/link-to}}</li>
At this point, we have a list of links in our app that transition us to the pokemon-details route with a dynamically added id. š„
Displaying more details š§
When we click on a link to view more details about a specific PokƩmon, we get the following error in our console:
Error while processing route: pokemon.pokemon-details
Ember Data Request GET /pokemon-details/19 returned a 404
In the network tab, we can see that the request was made to a localhost URL:
http://localhost:4200/pokemon-details/19
Since we defined an adapter to help us guide ember-data to the correct URL for our pokemon model, letās do the same for pokemon-details.
Ember CLI do your magic š§āāļø
ember g adapter pokemon-detail
In our adapters/pokemon-detail.js
file, we want to import our previous pokemon adapter and extend it with the buildURL method.
import Pokemon from './pokemon';export default Pokemon.extend({
buildURL (modelName, id) {
return `${this.host}/${this.namespace}/pokemon/${id}`;
}
});
Take a peek in the network tab. TA-DA! š©āš We have the correct response from the server, now letās display that data!
Here is an example of what I included in my templates/pokemon-detail.hbs
file, but feel free to get creative. š
š½
name: {{model.name}} <br>
height: {{model.height}} <br>
weight: {{model.weight}} <br><img src={{model.sprites.front_default}} alt="{{model.name}} front image">
<img src={{model.sprites.front_shiny}} alt="{{model.name}} front image">
<img src={{model.sprites.back_default}} alt="{{model.name}} back image">
<img src={{model.sprites.back_shiny}} alt="{{model.name}} back image">
Final touches šš¼
At this point, we have an app that displays a list of PokĆ©mon from PokeAPI and allows us to click on each PokĆ©mon to see its details. All we need is a way to navigate the list of PokĆ©mon returned in groups of 20 and weāre done. š“
First, weāll generate a controller for the pokemon route:
ember g controller pokemon
In controllers/pokemon.js
we will add two functions that enable or disable the next and previous buttons, and two actions to transition the pokemon route to a different pagination offset.
import Controller from '@ember/controller';
import { computed } from '@ember/object';export default Controller.extend({
queryParams: ['offset'],
offset: 0,
previousPageButtonIsDisabled: computed(
"model.offset",
function(){
return this.offset === 0 ;
}
),
nextPageButtonIsDisabled: computed(
"model.{offset,count}",
function() {
const offset = this.offset;
const itemsPerPage = 20;
const totalItemCount = this.model.count
return offset + itemsPerPage >= totalItemCount;
}
),actions: {
nextPage() {
this.transitionToRoute('pokemon', {
queryParams: {
offset: Number(this.offset) + 20
}
});
},previousPage() {
this.transitionToRoute('pokemon', {
queryParams: {
offset: Number(this.offset) - 20
}
});
}
}
});
All that we have left to do, is add two button HTML elements to our templates/pokemon.hbs
file.
<button
{{action "previousPage"}}
disabled={{previousPageButtonIsDisabled}}
>
Previous
</button><button
{{action "nextPage"}}
disabled={{nextPageButtonIsDisabled}}
>
Next
</button>
If you take a look at your app in the browser, youāll see that now you can click the next and previous buttons for more PokĆ©mon results, and display each PokĆ©monās details by clicking on its name. Also, the next and previous buttons are disabled when we reach the beginning and end of the results list.
Conclusion šš½āāļø
As we can see, working with ember-data can be both helpful and frustrating. Overall, I think itās a very powerful tool with a bit of a learning curve and unfortunately not too many real-life examples. Hopefully, this article will help you solve some common problems that I have encountered along the way and encourage you not to give up. š«
Thanks for reading!
Adam Skoczylas ā ember.js developer at SoapBox