by and

The Store without Ember Data

Developing with Ember.js, we are used to dealing with the Store service which many of us associate with Ember Data. It was while I was doing some research on how Ember integrates with data layers that I realised that the Store is part of an integration glue that exists in Ember even when Ember Data is not present. Moreover, you can create a light integration with a data layer by simply hooking into Ember's Store interface.

Here's a little guide for you to experiment with the possibilities.

Ember's minimal Store interface

First, to make sure we are not using Ember Data accidentally, let's remove it from our app. Simply remove the appropriate line from your packages.json and rerun npm install (or yarn install):

 1diff --git a/package.json b/package.json
 2index 5ba44f8..c536b45 100644
 3--- a/package.json
 4+++ b/package.json
 5@@ -32,7 +32,6 @@
 6     "ember-cli-shims": "^1.2.0",
 7     "ember-cli-sri": "^2.1.0",
 8     "ember-cli-uglify": "^2.0.0",
 9-    "ember-data": "~3.1.0",
10     "ember-export-application-global": "^2.0.0",
11     "ember-load-initializers": "^1.0.0",
12     "ember-maybe-import-regenerator": "^0.1.6",

So now we know there's no Ember Data. Next, let's say we have declared a route that looks like follows:

1/// app/router.js
2this.route('users', { path: '/users/:user_id' });

As well as this template:

1{{!--- app/templates/users.hbs ---}}
2<h1>User #{{model.id}}: {{model.name}}</h1>

If we do not provide a route module, Ember will provide one for us, using a default implementation. This will see the :user_id parameter last in the path, and from there it will figure out that we are expecting to retrieve a model called user , and its ID will be given in the URL. Roughly it will work like this:

1/// app/routes/users.js
2import Route from '@ember/routing/route';
3
4export default Route.extend({
5  model(params) {
6    return this.store.find('user', params.user_id);
7  }
8});

Note that this uses this.store.find() instead of this.store.findRecord()! Why is this? Well, there's a difference between Ember Data's interface and Ember's protocol to interact with a data layer.

Ember can integrate automatically with a data layer in this small way, providing a default route module and figuring out the model name and record id from the path. From here, a clever data layer can provide the appropriate integration hooks, and Ember will use them. In this case the hook is a store service which provides a find method.

But why find and not findRecord? Because Ember and Ember Data are independent. Long time ago it was established that this method would be called find, and initially Ember Data used find instead of findRecord. Eventually Ember Data moved on to a new interface, bringing the methods that we use nowadays. However Ember didn't need to follow suit, and anyway there were already other data layers that were implementing this interface already and there was no point on breaking their integrations. Ember Data does provide a find method which simply translates to findRecord internally, and all works as expected.

The default store

But anyway, as I was saying, somewhere in our app we have the following code:

1this.store.find('user', params.user_id);

When Ember Data is not present, Ember provides a default store. This store assumes that we have defined our models (in this case, a user model) in files living at app/models. This would be an example:

 1/// app/models/user.js
 2export default {
 3  find(id) {
 4    return new Promise(function(resolve) {
 5      resolve({
 6        id: 1,
 7        email: "[email protected]",
 8      });
 9    });
10  }
11}

(To be precise, Ember doesn't care about the location and name of this file, or even if there's a file. The important part is that the object above is registered as model:user in the dependency injection container, but that's a story for another day.)

This initial model is not very useful, returning a hard-coded object. This second approach would work with a simple REST API:

1/// app/models/user.js
2export default {
3  find(id) {
4    return fetch(`/users/${id}`);
5  },
6}

This approach is promising, but it has one big issue: we cannot use dependency injection in objects that we instantiate this way. For example, if instead of fetch we wanted to use the ajax service, or if we wanted to grab configuration details from another service, we'd be out of luck.

A custom store

To allow our custom models to play with the injection container, the simplest way might be to do just like Ember Data does, and provide our own store service.

This is an example of a custom store service:

 1/// app/services/store.js
 2import Service, { inject } from '@ember/service';
 3import { pluralize } from 'ember-inflector';
 4
 5export default Service.extend({
 6  ajax: inject(),
 7  find(model, id) {
 8    const resourcePath = pluralize(model);
 9    return this.ajax.request(`/${resourcePath}/${id}`);
10  },
11});

Then, to get this custom store injected by default in our routes, we can register it with the container in an initializer:

1/// app/instance-initializers/setup-store.js
2export function initialize(application) {
3  application.inject('route', 'store', 'service:store');
4}

And voila: your routers, default or otherwise, will be using your custom store.

This is the same approach used by Ember Data which, at the time of writing these lines, does exactly this: it injects its own store into routes, controllers, etc, which is then used transparently by us developers. See addon/setup-container.js on the Ember Data source code to check for yourself.