Skip to content

Instantly share code, notes, and snippets.

@foxnewsnetwork
Last active April 17, 2018 16:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save foxnewsnetwork/ed23e74045210255f232f34b22e70a84 to your computer and use it in GitHub Desktop.
Save foxnewsnetwork/ed23e74045210255f232f34b22e70a84 to your computer and use it in GitHub Desktop.
Advanced guide on how to write sensible ember components

Motivation

Have you ever run into the infamous ember double render bug?

Do you find yourself breaking out import { scheduleOnce } from '@ember/runloop' all the time?

Are you frustrated about "god" components that can't be tested?

If so, this gist is for you!

With these several simple tricks, you'll be able to write sensible components that are testable, simple, and maintainable. Developers hate him!

Trick 1: Use asychronous life cycle hooks

TL;DR

Use didInsertElement if you're bubbling actions, doing IO, or setting state instead of:

  • init
  • willRender
  • didRender
  • didReceiveAttrs
  • willReceiveAttrs
  • willInsertElement

Details

Consider the following code from this ember-twiddle:

application.hbs

{{! application.hbs }}
<h1>Welcome to {{appName}}</h1>
{{did-load (action (mut appName) 'render-1')}}

components/did-load.js

// did-load.js
// bad
export default Ember.Component.extend({
  willInsertElement() {
    Ember.tryInvoke(this, 'action');
  }
}).reopenClass({
	positionalParams: ['action']
});

Notice that this produces the infamous "double-render" bug in ember. This is because willInsertElement happens synchronously with the rendering of the hbs file. Consider the following render order diagram

glimmer render order for the above twiddle

Notice that, at the last step, glimmer has to go "backwards" in the rendering stack to update the value for appName. Imagine if glimmer had to go backwards in time for any given change to any property, the amount of recursive rendering glimmer would have to do would be horrendous, and might potentially never end if you give it something like the following:

<h1>{{foodName}} are delicious</h1>
{{#if (eq foodName 'dogs')}}
  {{did-load (action (mut foodName) 'cats')}}
{{/if}}

{{#if (eq foodName 'cats')}}
  {{did-load (action (mut foodName) 'dogs')}}
{{/if}}

A template like the above would instantly below the recursion stack in an user's browser and prevent any sensible debugging. To prevent this, ember throws an double-render assertion in development (so the developer can fix it), but in production, it'll simply refused to make the update until the next render cycle, thus causing framedrops and degrading performance.

So what should we do?

The answer is simple: replace synchronous hooks with async ones

Notably, the following lifecycle hooks are all synchronous:

  • init
  • didRender
  • didReceiveAttrs
  • willReceiveAttrs
  • willInsertElement

Therefore, if you're sending actions, setting state, performing IO, or any other "impure" monadic operation, you should replace with the async lifecycle hook:

  • didInsertElement

So back to our example of the did-load component, we can fix it by changing willInsertElement to didInsertElement like so:

// did-load.js
// fixed
export default Ember.Component.extend({
  didInsertElement() {
    Ember.tryInvoke(this, 'action');
  }
}).reopenClass({
	positionalParams: ['action']
});

Q&A

Q: Ok sure, but what if our app needs to some state mutation on each render? Will we have to use didRender and scheduleOnce together then?

A: Yes, but if your component is looking to do complicated state mutations on each render loop, that's a good sign it's doing too much. Consider breaking down your complicated component into simpler sub-components

Q: So what's safe to do in a synchronous life cycle hook (like init)?

A: Use init like you would an es6 constructor; let it setup your instances initial state, but don't let it IO or bubble actions. But in general, there's really very little reason to use the sync hooks, and if you find your component depending on their individual orderings, your component.js code is most likely getting too large.

Q: Is this a problem anywhere else?

A: Yes! See this article on async rendering from react

Trick 2: Separate tagless container from presentational ones

Taking a page from react's component paradigm of designing components that "just do one thing" (TM), we want to separate data-connect components from presentation ones. But what does this mean?

Getting down to nitty gritty, here are some rules:

Data Connect Components

These components served to interface with POJOs and other data. Therefore they should know nothing about presentation, and instead should know about the data flow of your app. They live in components/data-connect/*

  • Tagless tagName: ''
  • No associated style.scss
  • Can use service injections
  • Can have an actions hash
  • Can use integrated / async tools (e.g. ember-concurrency, saga, redux, Promises, ember-data, etc.)
  • template.hbs files can only invoke other data-connect/* components (this means no DOM elmenents either!)
  • Always yields state and actions in its template

Consider the following example:

components/data-connect/fetch-game.js

import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { connect } from 'ember-redux';
import { myFetchGame } from '../utils/fetch';

const statesToCompute = (state, attrs) => {
  return {
    game: state.games[attrs.id],
    isError: state.gameErrors[attrs.id] != null,
    lastError: state.gameErrors[attrs.id]
  };
};

export default connect(statesToCompute)(Component.extend({
  tagName: '',

  redux: service('redux'),

  fetchGameTask: task(function * (id) {
    try {
      const game = yield myFetchGame(id);
      this.get('redux').dispatch({ type: 'FOUND_GAME', game });
    } catch (error) {
      this.get('redux').dispatch({ type: 'FOUND_GAME', game });
    }

  }).drop(),

  actions: {
    findGame(id) {
      this.get('fetchGameTask').perform(id);
    }
  }
}));

components/templates/data-connect/my-games.hbs

{{yield 
  (hash
    isBusy=fetchGameTask.isBusy
    isError=isError
    error=lastError
    game=game
  )
  (hash
    findGame=(action 'findGame' id)
  )
}}

{{#isError}}
  {{data-connect/airbrake-reporter error=error}}
{{/if}}

While data-connect/* components are enabled to touch IO and be integrated with the rest of the app, they must be kept from the actual presentation. This is because, philosophically, data-connect/* components represent a way for the developer to build up a declaratively embedded heiroarchy in the retrieval, parsing, transformation, and IO handling of an app. (we'll cover why it's important to think in embedded hieroarchies in the last section)

Presentation Components

By contrast, a presentation component is confined to only live in components/presentation/* and must only be allowed to the following:

  • Must have a tagName
  • Can have classNames, classNameBindings, attributeBindings, and style.scss
  • Not allowed to have inject as service
  • Not allowed to have an actions hash
  • Must only have passed in state
  • Cannot use async / IO tools like ember-concurrency, fetch, generators, etc.,
  • Doesn't have to yield, but if it does, it may yield out render sections
  • template.hbs can only have other presentation/* components

Consider this typical example of a presentational component:

components/presentation/launch-button/component.js

export default Component.reopen({
  tagName: 'button',
  classNames: ['launch-button']
});

components/presentation/launch-button/component.hbs

{{fa-icon 'rocket'}}
<span class='launch-button__text'>Launch {{game.name}}</span>
{{yield}}

components/presentation/launch-button/style.scss

&. {
  background-color: #666;
}

Putting it together

Is it even possible to build a sensible application with rules this strictly? The answer is suprisingly yes. Consider the following example of how we would put together our my-game/:id route (we use some yet-to-be-defined routes):

my-game/template.hbs

{{#data-connect/my-game gameId=id as |state actions|}}
  <section class='game-overview' is-active={{state.isOverview}}>
    <h1>{{state.game.title}}</h1>

    {{launch-button game=game click=actions.launch}}
    {{#if state.isOverview}}
      {{gamepad-press/down 'DOWN' action=actions.goGallery}}
    {{/if}}
  </section>
  <section class='game-gallery' is-active={{state.isGallery}}>
    {{outlet}}
  </section>
{{/data-connect/my-game}}

my-game/gallery/template.hbs

{{gamepad-press/down 'UP' action=actions.goOverview}}
{{#data-connect/game-gallery-images game=game as |state actions|}}
  <ul>
    {{#each state.images as |image|}}
      <li><img src={{image.src}} alt={{image.alt}}></li>
  </ul>
{{/data-connect/game-gallery-images}}

The critical thing to notice is that we follow the rule of alternating presentation hbs code with data-connection hbs code like so:

{{#data-connect/a as |stateA actionsA|}}
  {{presentation/a}}

  {{#presentation/a2}}
    {{#if stateA.isA2Active}}
      {{key-press/down 'ENTER' action=actionsA.doSomething}}
    {{/if}}

    {{#data-connect/b as |stateB actionsB|}}
      {{presentation-b}}
    {{/data-connect/b}}
  {{/presentation/a2}}
{{/data-connect/a}}

This structure has the following benefits:

  • all data and present components can be tested entirely independent of each other
  • areas like the nesting of {{data-connect/b}} inside {{data-connect/a}} represent nature areas of route transition
  • the usage of state and actions from data components mirrors the functional state transition api of redux

In sort, our app consists would look something like the following:

  • app
    • components
      • presentation
        • launch-button
        • grid-tiles
        • game-tile
        • video-player
      • data-connect
        • home
        • my-game
        • all-games
        • game-gallery-images

Theoretically, once we get routing in components, nothing will stop use from introducing a third component category components/routes/* which are just namespaces for the current routing system

Trick 3: Always ask why

  • Why did we want to avoid putting logic into templates
  • What is the purpose of DRY?
  • Why do we want to avoid complexity?
  • Why is the context api bad?

Trick 4: Never register upward

  • One direction DDAU
  • Use yield

Trick 5: Think in types

  • Avoid patterns that can't be typed
  • More concurrency is not the way to solve your existing concurrency problems
  • Avoid monadic code when possible

Trick 6: Closure actions instead of bubbling actions

Trick 7: Use higher order functions instead of mixins

Trick 8: Library over framework

Trick 9: Think in embedded hieroarchies

General Philosophy

  • Fragility v. Robustness v. Volaphilia (aka antifragility) in the world of systems architecture
    • what makes certain architecture fragile, robust, or even volaphilic
    • why components lends itself to being more robust
  • Refactor early and often
  • Learn what other frameworks / libraries / languages are doing
  • Make mistakes, just don't be scared to fix them
@foxnewsnetwork
Copy link
Author

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment