Render Element Modifiers
Summary
Element modifiers are a recently introduced concept in Ember that allow users to run code that is tied to the lifecycle of an element in a template, rather than the component's lifecycle. They allow users to write self-contained logic for manipulating the state of elements, and in many cases can be fully independent of component code and state.
However, there are many cases where users wish to run some component code when
an element is setting up or tearing down. Today, this logic conventionally lives
in the didInsertElement
, didRender
, didUpdate
, and willDestroyElement
hooks in components, but there are cases where these hooks are not ideal.
This RFC proposes creating an official Ember addon which provides three new
generic element modifiers: {{did-insert}}
, {{did-update}}
, and
{{will-destroy}}
. Users will be able to use these to run code during the most
common phases of any element's lifecycle.
Motivation
The primary component hooks for interacting with the DOM today are:
didInsertElement
didRender
didUpdate
willDestroyElement
These render hooks cover many use cases. However, there are some cases which they do not cover, such as setting up logic for conditional elements, or tagless components. There also is no easy way to share element setup logic, aside from mixins or pure functions (which require some amount of boilerplate).
Conditionals
Render code for elements which exist conditionally is fairly tricky. Consider a simple popover component:
{{#if this.isOpen}}
<div class="popover">
{{yield}}
</div>
{{/if}}
If the developer decides to use an external library like Popper.js to position the popover, they have to add a fair amount of boilerplate. On each render, they need to check if the popover was added to the DOM or removed from it, and setup or teardown accordingly.
export default Component.extend({
didRender() {
if (this.isOpen && !this._popper) {
let popoverElement = this.element.querySelector('.popover');
this._popper = new Popper(document, popoverElement);
} else if (this._popper) {
this._popper.destroy();
}
},
willDestroyElement() {
if (this._popper) {
this._popper.destroy();
}
}
});
At this level of complexity, most developers would reasonably choose to create
a second component to be used within the {{if}}
block so they can use standard
lifecycle hooks. Sometimes this makes sense as it helps to separate concerns and
organize code, but other times it is clearly working around the limitations of
render hooks, and can feel like more components are being created than are
necessary.
With render modifiers, hooks are run whenever the element they are applied to is setup and torn down, which means we can focus on the setup and teardown code without worrying about the overall lifecycle:
{{#if this.isOpen}}
<div
{{did-insert (action this.setupPopper)}}
{{will-destroy (action this.teardownPopper)}}
class="popover"
>
{{yield}}
</div>
{{/if}}
export default Component.extend({
setupPopper(element) {
this._popper = new Popper(document, element);
},
teardownPopper() {
this._popper.destroy();
}
});
The element that the modifiers are applied to is also passed to the function, so
there is no longer a need to use querySelector
. Overall the end result is a
fair amount simpler, without the need for an additional component.
These same issues are also present for collections items within an {{each}}
loop, and the render modifiers can be used to solve them as well:
<ul>
{{#each items as |item|}}
<li
{{did-insert (action this.registerElement)}}
{{will-destroy (action this.unregisterElement)}}
>
...
</li>
{{/each}}
</ul>
Tagless Components
Additionally, render hooks do not provide great support for tagless components
(tagName: ''
). While the hooks fire when the component is rendered, they have
no way to target any of the elements which are in the component's template,
meaning users must use querySelector
and setup some unique id or class to
target the element by:
export default Component.extend({
tagName: '',
listId: computed(function() {
return generateId();
}),
didInsertElement() {
let element = document.querySelector(`#${this.listId}`);
// ...
},
willDestroyElement() {
let element = document.querySelector(`#${this.listId}`);
// ...
}
});
<ul id={{listId}}>
...
</ul>
<div>
...
</div>
The render modifiers can be used to add hooks to the appropriate main element in tagless components:
export default Component.extend({
tagName: '',
didInsertList(element) {
// ...
},
willDestroyList(element) {
// ...
}
});
<ul
{{did-insert (action this.didInsertList)}}
{{will-destroy (action this.willDestroyList)}}
>
...
</ul>
<div>
...
</div>
Reusable Helpers
Currently, the best ways to share element setup code are either via mixins, which are somewhat opaque and can encourage problematic patterns, or standard JS functions, which generally require some amount of boilerplate.
Developers will be able to define element modifiers in the future with modifier managers provided by addons. However, the proposed modifier APIs are fairly verbose (with good reason) and not stabilized.
However, the render modifiers can receive any function as their first
parameter, allowing users to share and reuse common element setup code with
helpers. For instance, a simple scrollTo
helper could be created to set the
scroll position of an element:
// helpers/scroll-to.js
export default function scrollTo() {
return (element, [scrollPosition]) => element.scrollTop = scrollPosition;
}
<div
{{did-insert (scroll-to) @scrollPosition}}
{{did-update (scroll-to) @scrollPosition}}
class="scroll-container"
>
...
</div>
Official Addon
While these modifiers will be generally useful, modifiers are meant to be a more generic API that can be used to create libraries for solving specific problems. Unfortunately, the community hasn't had much time to experiment with modifiers, since the public API for them hasn't been finalized.
The modifiers in this RFC will provide an basic stepping stone for users who want to emulate lifecycle hooks and incrementally convert their applications to modifiers while modifiers in general are being experimented with in the community. In time, users should be able to pick and choose the modifiers that suit their needs more directly and effectively, and they shouldn't have to include these modifiers in the payload. These modifiers should also not be seen as the "Ember way" - they are just another addon, a basic one supported by the Ember core team, but one which may or may not be appropriate for a given application.
Detailed design
This RFC proposes adding three element modifiers:
{{did-insert}}
{{did-update}}
{{will-destroy}}
Note that element modifiers do not run in SSR mode - this code is only run on clients. Each of these modifiers receives a callback as it's first positional parameter:
type RenderModifierCallback = (element: Element, positionalArgs: [any], namedArgs: object): void;
The element
argument is the element that the modifier is applied to,
positionalArgs
contains any remaining positional arguments passed to the
modifier besides the callback, and namedArgs
contains any named arguments
passed to the modifier. If the first positional argument is not a callable
function, the modifier will throw an error.
Note: The timing semantics in the following section were mostly defined in the element modifier manager RFC and are repeated here for clarity and convenience.
{{did-insert}}
This modifier is activated only when The element is inserted in the DOM.
It has the following timing semantics when activated:
- Always
- called after DOM insertion
- called after any child element's
{{did-insert}}
modifiers - called after the enclosing component's
willRender
hook - called before the enclosing component's
didRender
hook - called in definition order in the template
- May or May Not
- be called in the same tick as DOM insertion
- have the sibling nodes fully initialized in DOM
Note that these statements do not refer to when the modifier is activated, only to when it will be run relative to other hooks and modifiers should it be activated. The modifier is only activated on insertion.
{{did-update}}
This modifier is activated only on updates to it's arguments (both positional and named). It does not run during or after initial render, or before element destruction.
It has the following timing semantics when activated:
- Always
- called after the arguments to the modifier have changed
- called after any child element's
{{did-update}}
modifiers - called after the enclosing component's
willUpdate
hook - called before the enclosing component's
didUpdate
hook - called in definition order in the template
- Never
- called if the arguments to the modifier are constants
{{will-destroy}}
This modifier is activated:
- immediately before the element is removed from the DOM.
It has the following timing semantics when activated:
- Always
- called after any child element's
{{will-destroy}}
modifiers - called before the enclosing component's
willDestroy
hook - called in definition order in the template
- May or May Not
- be called in the same tick as DOM removal
Function Binding
Functions which are passed to these element modifiers will not be bound to any
context by default. Users can bind them using the (action)
helper:
<div {{did-insert (action this.setupElement)}}></div>
Or by using the @action
decorator provided by the
Decorators RFC to bind the function
in the class itself:
export default class ExampleComponent extends Component {
@action
setupElement() {
// ...
}
}
<div {{did-insert this.setupElement}}></div>
How we teach this
Element modifiers will be new to everyone, so we're starting with a mostly blank
slate. The only modifier that exists in classic Ember is {{action}}
, and while
most existing users will be familiar with it, that familiarity may not translate
to the more general idea of modifiers.
The first thing we should focus on is teaching modifiers in general. Modifiers
should be seen as the place for any logic which needs to act directly on an
element, or when an element is added to or removed from the DOM. Modifiers can
be fully independent (for instance, a scroll-to
modifier that transparently
manages the scroll position of the element) or they can interact with the
component (like the did-insert
and will-destroy
modifiers). In all cases
though, they are tied to the render lifecycle of the element, and they
generally contain side-effects (though these may be transparent and
declarative, as in the case of {{action}}
or the theoretical {{scroll-to}}
).
Second, we should teach the render modifiers specifically. We can do this by illustrating common use cases which can currently be solved with render hooks, and comparing them to using modifiers for the same solution. We should also emphasize that these are an addon, not part of the core framework, and are useful as solutions for specific problems. As more modifiers become available, we should create additional guides that focus on using the best modifier for the job, rather than these generic ones.
One thing we should definitely avoid teaching except in advanced cases is the ordering of element modifiers. Ideally, element modifiers should be commutative, and order should not be something users have to think about. When custom element modifiers become widely available, this should be considered best practice.
Example: Scrolling an element to a position
This sets the scroll position of an element, and updates it whenever the scroll position changes.
Before:
{{yield}}
export default Component.extend({
classNames: ['scroll-container'],
didRender() {
this.element.scrollTop = this.scrollPosition;
}
});
After:
<div
{{did-insert this.setScrollPosition @scrollPosition}}
{{did-update this.setScrollPosition @scrollPosition}}
class="scroll-container"
>
{{yield}}
</div>
export default class Component.extend({
setScrollPosition(element, scrollPosition) {
element.scrollTop = scrollPosition;
}
})
Example: Adding a class to an element after render for CSS animations
This adds a CSS class to an alert element in a conditional whenever it renders to fade it in, which is a bit of an extra hoop. For CSS transitions to work, we need to append the element without the class, then add the class after it has been appended.
Before:
{{#if shouldShow}}
<div class="alert">
{{yield}}
</div>
{{/if}}
export default Component.extend({
didRender() {
let alert = this.element.querySelector('.alert');
if (alert) {
alert.classList.add('fade-in');
}
}
});
After:
{{#if shouldShow}}
<div {{did-insert this.fadeIn}} class="alert">
{{yield}}
</div>
{{/if}}
export default Component.extend({
fadeIn(element) {
element.classList.add('fade-in');
}
});
Example: Resizing text area
One key thing to know about {{did-update}}
is it will not rerun whenever the
contents or attributes on the element change. For instance, {{did-update}}
will not rerun when @type
changes here:
<div {{did-update this.setupType}} class="{{@type}}"></div>
If {{did-update}}
should rerun whenever a value changes, the value should be
passed as a parameter to the modifier. For instance, a textarea which wants to
resize itself to fit text whenever the text is modified could be setup like
this:
<textarea {{did-update this.resizeArea @text}}>
{{@text}}
</textarea>
export default Component.extend({
resizeArea(element) {
element.css.height = `${element.scrollHeight}px`;
}
});
Example: ember-composability-tools
style rendering
This is the type of rendering done by libraries like ember-leaflet
, which use
components to control the rendering of the library, but without any templates
themselves. The underlying library for this is here.
This is a simplified example of how you could accomplish this with Glimmer
components and element modifiers.
Node component:
// components/node.js
export default Component.extend({
init() {
super(...arguments);
this.children = new Set();
this.parent.registerChild(this);
}
willDestroy() {
super(...arguments);
this.parent.unregisterChild(this);
}
registerChild(child) {
this.children.add(child);
}
unregisterChild(child) {
this.children.delete(child);
}
didInsertNode(element) {
// library setup code goes here
this.children.forEach(c => c.didInsertNode(element));
}
willDestroyNode(element) {
// library teardown code goes here
this.children.forEach(c => c.willDestroyNode(element));
}
}
<!-- components/node.hbs -->
{{yield (component "node" parent=this)}}
Root component:
// components/root.js
import NodeComponent from './node.js';
export default NodeComponent.extend();
<!-- components/root.hbs -->
<div
{{did-insert (action this.didInsertNode)}}
{{will-destroy (action this.willDestroyNode)}}
>
{{yield (component "node" parent=this)}}
</div>
Usage:
<Root as |node|>
<node as |node|>
<node />
</node>
</Root>
Drawbacks
Adding these modifiers means that there are more ways to accomplish similar goals, which may be confusing to developers. It may be less clear which is the conventional solution in a given situation.
Relying on users binding via
action
is somewhat unintuitive, and may feel like it's getting in the way, especially considering sometimes methods will work without binding (if they never accessthis
).
Alternatives
- Stick with only lifecycle hooks for these situations, and don't add generic modifiers for them.