LUX
Dive into modern @ember-data - Part 1

At EmberFest 2019 runspired, one of the main developers of @ember-data, gave an amazing talk about the future of @ember-data. The vision completely fascinated me, and I had some amazing conversations with runspired after. Over the years, we had a few more very interesting conversations, and recently we had a longer chat about all of this, and what is now already implemented in @ember-data as public API and ready to use.

Now this will (hopefully) be a little series about the future of @ember-data that we already have in parts, and how to use it, from the perspective of someone that didn't develop it.

As ember-data users we currently mostly write instances of @ember-data/model, use the methods findAll, findRecord, query, queryRecord, peek and peekRecord on the Store Service. Now while the methods on the Store are usually nice to use, @ember-data/model feels in many ways a bit outdated, and especially relationships often can feel clumsy to use in modern ember. We maybe also sometimes write a Serializer or Adapter, or use the default ones, but that's very often a once-per-project kind of work.

Over the last years ember-data has been split into multiple packages under the @ember-data namespace, while ember-data just glues these together. The center of all of this is @ember-data/store. Then @ember-data/record-data is where the actual data is stored. This is currently build for JSON:API style of data, however, I know the @ember-data Team has plans for things like GraphQL.

Now our goal in this series is to completely replace @ember-data/model with something custom.

The Schema and the Model

We currently write Model classes for two reasons:

  1. We declare the Schema of our data here. Namely which attributes we have, which transforms we use (like @attr('date')), and which relationships we have.
  2. We use this classes as actual instances of our data. We can define computed properties on them, methods, and generally just pass them around.

To allow other approaches for models and APIs, @ember-data/store, the core of everything, now seperates these two things. @ember-data/model currently provides both. On our road to replace @ember-data/model with something custom, we will also need to replace both. While I first wanted to only replace the Schema, and later the presentation classes, this seems not possible.

Starting the project

We can start with a plain new ember project, remove ember-data from the dependencies, but instead use:

"@ember-data/record-data": "4.8.0-beta.0",
"@ember-data/store": "4.8.0-beta.0",

We did not add @ember-data/model, since this is precisely what we want to build on our own.

We also will not use @ember-data/adapter or @ember-data/serializer at all. Serializers, as a concept, are a bit a strange separation from the adapter anyway. While the customization of the standard adapter and serializer can be very nice, when implementing both from scratch, I find myself often wondering why I can't just directly return the JSON:API data from the adapter. Because of this, serializers are completely optional in recent @ember-data. And while we do need to write an adapter, we just need to implement the minimal interface, and no longer need to extend from a special base class.

A trivial implementation

The first thing of all is that we need to provide our own extension of the Store. We can start with something very trivial:

import BaseStore from '@ember-data/store';

export default class Store extends BaseStore {
}

Now we slowly will need to add capabilities to our Store. We will need to add an adapter, a schema, and some kind of models.

Lets start with a little adapter, to actually give us some data. We will just return static data for now, so something like this will do:

class Adapter {
  async query() {
    const data = [
      {
        id: 'one',
        type: 'movie',
        attributes: {
          name: 'Matrix',
          director: 'Wachowski',
          releaseDate: '1999-03-24',
        },
      },
      {
        id: 'two',
        type: 'movie',
        attributes: {
          name: 'Avatar',
          genre: 'SciFi',
        },
      },
    ];

    return { data };
  }
}

This will not be used or imported in any magical way. We just overwrite adapterFor in our Store to return the adapter:

export default class Store extends BaseStore {
  adapter = new Adapter();
  adapterFor() {
    return this.adapter;
  }
}

The next thing we need is a Schema. We can also start with something trivial:

class Schema {
  attributes = new Map();
  doesTypeExist() {
    return true;
  }

  attributesDefinitionFor({ id, type }) {
    if (type === 'movie') {
      return {
        name: { name: 'name'},
        director: { name: 'director'},
        releaseDate: { name: 'releaseDate'},
        genre: { name: 'genre'},
      };
    }
    return {};
  }

  relationshipsDefinitionFor({ id, type }) {
    return {};
  }
}

We need to register our Schema in the Store as well. This can be done in the constructor:

constructor() {
  super(...arguments);
  this.registerSchemaDefinitionService(new Schema());
}

These were all preconditions we need to provide our own Model class. For this we will need to implement instantiateRecord in our Store. Lets first have a look at its signature:

instantiateRecord(
  identifier: StableRecordIdentifier,
  createRecordArgs: { [key: string]: unknown },
  recordDataFor: (identifier: StableRecordIdentifier) => RecordData,
  notificationManager: NotificationManager
): DSModel | RecordInstance

The important pieces for now are the identifier, which has a type property, and recordDataFor, which will allow us to actually access the data stored in @ember-data/record-data. As mentioned in the beginning, this is the place where all data is stored. Our Model now needs to implement getters and setters for all attributes, to proxy the data to RecordData. The data in RecordData is always stored in its serialized form. So for things like Dates, we need to provide a getter/setter pair, that can transform it on the fly. This underlines the role of the Model as a presentation layer of the data, so it is nice to use for our application.

We can now write a simple Model class:

class Movie {
  constructor(identifier, recordDataFor) {
    this.identifier = identifier;
    this.recordDataFor = recordDataFor;
  }

  get name() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'name');
  }
  set name(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'name', value);
  }

  get director() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'director');
  }
  set director(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'director', value);
  }

  get releaseDate() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'releaseDate');
  }
  set releaseDate(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'releaseDate', value);
  }

  get genre() {
    const recordData = this.recordDataFor(this.identifier);
    return recordData.getAttr(this.identifier, 'genre');
  }
  set genre(value) {
    const recordData = this.recordDataFor(this.identifier);
    recordData.setAttr(this.identifier, 'genre', value);
  }
}

Writing manual getters and setters for all attributes is very cumbersome, and something we will address in the next steps. However, it's important to note that the Model does not actually store anything, but proxies all data to RecordData.

We can now use our Model class in the Store:

instantiateRecord(
  identifier,
  createRecordArgs,
  recordDataFor,
  notificationManager
) {
  if(identifier.type === 'movie') {
    return new Movie(identifier, recordDataFor);
  }
}

Now we can just do this.store.query('movie', {}) to retrieve the data.

Outlook

While we have a nice little demo so far, there is much to do and explore. In future blog posts we will look into these topics. We will see how we can avoid the cumbersome getters in the model class, and how we can automatically infer the Schema from the data when using JSON:API. We also did not yet look into relationships at all, which is something we definitely will need to do.

What ember means by "modified twice in a single render"

Sometimes when working with ember people encounter a message like this one:

Uncaught Error: Assertion Failed: You modified "tabs.length" twice on <tabs@component:tab::ember220> in a single render.
It was rendered in "component:tabs" and modified in "component:tab".
This was unreliable and slow in Ember 1.x and is no longer supported.
See https://github.com/emberjs/ember.js/issues/13948 for more details.

Usually this happens when something that already has been rendered was later modified in the same rendering cycle.

This often happens when a contexual component tries to track its children. Assuming we want a tabbing component that we can use like this:

<Tabs as |t|>
  <t.tab @title="First">
    This is the first
  </t.tab>

  <t.tab @title="Second">
    This is the second
  </t.tab>
</Tabs>

Then we could try to implement this with the following components:

Tabs

<div class="tabbed-context">
  <header>
    {{#each this.tabs as |tab|}}
      <button onclick={{action (mut this.active) tab.title}}>
        {{tab.title}}
      </button>
    {{/each}}
  </header>
  <main>
    {{yield (hash
      tab=(component "tab"
        registerTab=(action 'registerTab')
        active=this.active
      )
    )}}
  </main>
</div>
import Component from '@ember/component';
import { later } from '@ember/runloop';

export default Component.extend({
  tagName: '',
  init() {
    this._super(...arguments);
    this.set('tabs', []);
  },
  actions: {
    registerTab(tab) {
      // this is the problematic line:
      this.get('tabs').pushObject(tab);
    },
  },
});

Tab

{{#if this.isActive}}
  {{yield}}
{{/if}}
import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  tagName: '',
  init() {
    this._super(...arguments);
    this.registerTab(this);
  },
  isActive: computed('active', 'title', {
    get() {
      return this.active === this.title;
    }
  }),
});

Now to understand the problem that happens in the marked line we need to think how we would render this:

  • ember creates the tabs component
  • it executes the init hook and sets the tabs property to an empty array
  • it renders the <header> tag
  • it encounters {{#each this.tabs as |tab|}} but because this.tabs is empty it omits it. This is important, we will come back to this later.
  • it creates the <main> tag
  • it encounters the yield, meaning it goes back to the outer template
  • it encounters <t.tab @title="First"> meaning it has to create a tab component
    • it sets registerTab and active from the tabs component to the new tab component.
    • it executes the init hook.
    • Because of the this.registerTab(this); call it will execute registerTab on the tabs component
    • This will execute this.get('tabs').pushObject(tab);. However because the tabs array was already used to (not) render {{#each this.tabs as |tab|}} we've modified something that was already rendered which we should not. Resulting in the error.

The reason why we should not do this is because at that point ember basically has to abort the rendering and jump back to that point. If this happens multiple times (here for every tab) this will result in poor performance.

Now the fix is to delay that modification. For this we can wrap this.get('tabs').pushObject(tab); inside later:

import { later } from '@ember/runloop';
...

later(() => this.get('tabs').pushObject(tab));

this will modify the tabs array after the initial rendering. This means that ember

  1. renders everything with an empty tabs array,
  2. calls all the this.get('tabs').pushObject(tab) for all tabs, effectivly fully populating the tabs array, and then
  3. rerendering everything with the full tabs array.