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!
Use didInsertElement
if you're bubbling actions, doing IO, or setting state instead of:
- init
- willRender
- didRender
- didReceiveAttrs
- willReceiveAttrs
- willInsertElement
Consider the following code from this ember-twiddle:
application.hbs
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
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:
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: 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
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:
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 otherdata-connect/*
components (this means no DOM elmenents either!)- Always
yield
sstate
andactions
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
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)
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
, andstyle.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 mayyield
out render sections template.hbs
can only have otherpresentation/*
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
components/presentation/launch-button/style.scss
&. {
background-color: #666;
}
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
my-game/gallery/template.hbs
The critical thing to notice is that we follow the rule of alternating presentation hbs code with data-connection hbs code like so:
This structure has the following benefits:
- all
data
andpresent
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- futhermore, if routeable components ever lands, we'll be more than ready for them
- occasionally check in with my work-in-progress repo for an
hbs
favored ember routing system
- the usage of
state
andactions
fromdata
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
- presentation
- components
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
- 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?
- One direction DDAU
- Use
yield
- Avoid patterns that can't be typed
- More concurrency is not the way to solve your existing concurrency problems
- Avoid monadic code when possible
- 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
References