Skip to content

Instantly share code, notes, and snippets.

@tomdale
Last active December 22, 2018 08:27
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 tomdale/351499a6e5f53008fd41cbd5b767f9dc to your computer and use it in GitHub Desktop.
Save tomdale/351499a6e5f53008fd41cbd5b767f9dc to your computer and use it in GitHub Desktop.
Ember Component Lifecycle Hooks Survey

Component Lifecycle Hooks Survey

Most Ember developers are familiar with component lifecycle hooks, like didInsertElement, willDestroy, etc.

As we think about future APIs for Glimmer components that improve on lifecycle hooks, we want to make sure that we're providing abstractions that are simple, performant, and ergonomic, while nudging users towards good patterns and away from potential footguns.

To that end, I'd like to ask the community to help me gather use cases that rely on component lifecycle hooks. We want to understand how they're being used in the real world today.

Please audit your own applications and share use cases as a comment below, using the following template:

### My Use Case

A few sentences about this use case, and any additional context needed to understand it.

**Component Prevalence**: Very Rare | Rare | Uncommon | Common | Very Common  
**App Prevalence**: Very Rare | Rare | Uncommon | Common | Very Common

```
Example JavaScript/template goes here
```

Component Prevalence is a rough estimate of how often this use case comes up when writing components day-to-day. Is it something you encounter frequently when implementing a feature? Or is it a power tool that most developers won't have to deal with most days?

App Prevalence is a rough estimate of how often the average Ember app would have at least one instance of this use case.

These are broken out into separate figures because some use cases may be very rare in a given component, but almost every app will encounter it eventually. Other times, a use case is not that common in every Ember app, but apps that do run into it run into it frequently.

The goal here is to drive the API design through real-world motivating examples. Having example code lets us see how new API proposals will look when used to solve these problems, and understanding the frequency with which this use case pops up helps shape what areas should get more sugary, shorthand syntax.

Some guidelines:

  1. Avoid non-idiomatic cases. Please don't include use cases that, given your understanding of the Ember programming model today, are better handled other ways. For example, setting a DOM element's attribute to the value of a component property is probably better done in the component's template, not by running imperative JavaScript code in didInsertElement.
  2. Distill the use case. If possible, also try to identify the higher-level behavior that is required. For example, "Sending an event to Mixpanel whenever the component's tracking ID changes" is great, but if you can also identify that this is more generically "Performing async behavior in response to an argument change," it's easier to group seemingly-unrelated use cases.
  3. Simplify the example. Please don't copy-paste a thousand line component from your app as the example. Instead, try to reduce the example down to the smallest possible snippet of code that gets the specific use case across.
  4. Avoid truly unlikely cases. For example, if your manager instructed you to instrument every component in your app with a didInsertElement hook that fires synchronous XHR to collect analytics "because we're a data-driven company and more data is always better," that's probably not a common use case we'd want to optimize our API design for. (There's also an #ember-jobs channel in Discord so you can get out of this situation.)
  5. Don't worry about the prevalence estimates too much. There will be some things that are used everywhere in one app and not used at all in another. If you're not sure, just estimate these numbers based on what you've seen in your own apps/addons, or leave it out altogether.

Here are a few motivating examples I've gathered already.

Data Fetching

In many cases, it's desirable to move data fetching out of the Route and into a component. For example, imagine a component that fetches the latest weather information from a JSON API and displays it. If this data only needs to be fetched once, this can be done in init or didInsertElement.

Component Prevalence: Uncommon (most data fetching is relatively centralized in a few components)
App Prevalence: Very Common (almost every app performs some data fetching in a component)

class Weather extends Component {
  async didInsertElement() {
    let request = await fetch('https://api.example.com/weather/10001.json');
    let weather = await request.json();
    this.set('weather', weather);
  }
}

Argument-Based Data Fetching

This one was brought up by @Guarav0 in the Glimmer Components quest issue. In many cases, you may want the data a component fetches to be determined by one or more arguments that are passed to the component. That data should be updated if those arguments change. In the above Weather component, for example, we may not want to hardcode the zip code and instead make it configurable.

Component Prevalence: Uncommon (most data fetching is relatively centralized in a few components)
App Prevalence: Very Common (almost every app performs some data fetching in a component)

class Weather extends Component {
  async didReceiveAttrs() {
    let zipCode = this.zipCode;
    let request = await fetch(`https://api.example.com/weather/${zipCode}.json`);
    let weather = await request.json();
    this.set('weather', weather);
  }
}

Resource Setup and Teardown

This is for cases where you have some behavior that should start when the component is rendered, and must be torn down when the component is destroyed to avoid memory leaks, performance problems, or other bugs.

Component Prevalence: Rare (usually abstracted over due to ease of getting setup/teardown wrong)
App Prevalence: Common (may not be needed in smaller apps, used frequently in apps with real-time features or with sophisticated UI requirements)

class StickyHeader extends Component {
  didInsertElement() {
    this.onScroll = () => {
      // ...
    };
    window.addEventListener('scroll', this.onScroll);
  }

  willDestroyElement() {
    window.removeEventListener('scroll', this.onScroll);
  }
}
class StockTicker extends Component {
  @service socket;

  didInsertElement() {
    this.socket.open();
  }

  willDestroyElement() {
    this.socket.close();
  }
}

Third-Party DOM Libraries (D3, jQuery UI)

This is similar to "Resource Setup and Teardown" but with the additional requirement of needing to reflect changes from Ember out into a third-party library, and in some cases listening for changes in that library and reflecting them back to Ember.

Component Prevalence: Rare (usually abstracted over due to ease of getting things wrong)
App Prevalence: Rare (going out on a limb to say this isn't needed by most modern Ember apps, due to diminished popularity of jQuery plugins, and most DOM interaction is better handled via components/templates)

class StockTicker extends Component {
  items = ['a', 'b', 'c'];

  didInsertElement() {
    let svg = d3.select(`#${this.elementId} svg`);
    this.set('svg', svg);
    this.updateSVG();
  }

  didUpdateAttrs() {
    this.updateSVG();
  }

  updateSVG() {
    let { svg, items } = this;

    let text = svg.selectAll('text').data(items);
    text.enter().append('text')
      .merge(text)
        .text(label => label);
    text.exit().remove();
  }
}

Measuring DOM Element Dimensions

Sometimes you need to lay out UI elements relative to some DOM element, but you don't know the dimensions of that element ahead of time. For example, you may want to know the height of an element that contains text content, or position a popover relative to some other element on the page.

Component Prevalence: Rare (most content is laid out with CSS, DOM measurement is avoided when possible due to performance impact, often abstracted by addons due to difficulty of avoiding bugs)
App Prevalence: Uncommon (usually only needed in apps with more sophisticated UI design where CSS solution isn't available)

class Popover extends Component {
  didRender() {
    let targetEl = document.querySelector(this.targetSelector);
    let bounds = targetEl.getBoundingClientRect();
    this.positionPopoverRelativeToBounds(bounds);
  }

  positionPopoverRelativeToBounds(bounds) {
    // ...
  }
}
@NullVoxPopuli
Copy link

NullVoxPopuli commented Dec 21, 2018

Rendering before data is received / ready

In this component, after render, a loader is shown, because the rendering component / context doesn't yet have the data that this component needs, represented by hasDeliveryConfirmations. If hasDeliveryConfirmations never becomes true, this component then renders a timeout message.

This could be common in optimisic UI scenarios, where the data in flight is not managed by an optimistic store. I could see a pattern where a component is rendered with expected data, and then has an indicator / loader that shows before the data is successfully committed.

Component Prevalence: Rare (most data is known ahead of render)
App Prevalence: Common (every app I've seen has scenarios where UI is rendered before the data is ready for it in order to appear snappy)

export default class DeliveryConfirmation extends Component {
  timedOut = false;

  didInsertElement() {
    this.waitForConfirmation.perform();
  }

  @dropTask
  *waitForConfirmation(this: DeliveryConfirmation) {
    if (this.timedOut) return;

    yield timeout(TIMEOUT_MS);

    if (!this.hasDeliveryConfirmations) {
      this.timedOut = true;
    }
  }
}

full code: https://gitlab.com/NullVoxPopuli/emberclear/blob/master/packages/frontend/src/ui/components/chat-history/message/delivery-confirmations/component.ts

@NullVoxPopuli
Copy link

NullVoxPopuli commented Dec 21, 2018

Conditionally Automatic UI Control

In my app, there is a chat message log, where the most recent message displays at the bottom. Sometimes, there will be a link, and the open-graph data for that link will be asynchronously fetched and rendered. When the open graph card is rendered, it is rendered below the viewport. In order to have that open graph data visible, the scrollable div needs to scroll when the card is rendered. In the code below, I've abstracted away all the scoll logic / non-scrcoll logic to a service, but it's triggered via the didInsertElement hook.

Component Prevalence: Rare (most components should not control scroll / other user-land things)
App Prevalence: Uncommon (this may be specific to chat apps, or anything with inverted scrolling -- where the newest thing is at the bottom and has asynchronously loaded data)

export default class MetadataPreview extends Component {
  @service chatScroller!: ChatScroller;

  didInsertElement() {
    this.chatScroller.maybeNudgeToBottom(this.element);
  }
}

full code: https://gitlab.com/NullVoxPopuli/emberclear/blob/master/packages/frontend/src/ui/components/chat-history/message/embedded-resource/metadata-preview/component.ts

@bendemboski
Copy link

Actions Down Component Registration

I occasionally need a parent component/controller to be able to call an action on a child component, e.g. a multi-column page where the left column acts as a navigation panel for a list of editors in the main column, so clicking on one scrolls to and then focuses the editor.

To accomplish this, the parent controller/component passes a registration action into the child component that the child component calls in its didInsertElement, giving the parent a reference back to the child so it can call the action.

Component Prevalence: Rare (the DDAU pattern handles most parent/child communication use cases)
App Prevalence: Rare (I don't think very many applications have such use cases)

// components/editor.js
didInsertElement() {
  if (this.registerFocusEditor) {
    this.registerFocusEditor(() => this.element.querySelector('.editor').focus());
  }
},

willDestroyElement() {
  if (this.registerFocusEditor) {
    this.registerFocusEditor(null);
  }
}

@bendemboski
Copy link

Event Bus Actions Down Component Communication

Similar to Actions Down Component Registration but when it's more of a broadcast pattern (perhaps with feedback). Imagine a form that is itself a list of forms for creating/editing customer info. Each customer info form has a save button and the wrapper form has a 'save all' button and a 'reset all' button to discard any unsaved changes.

The parent form creates an Evented EmberObject that it passes to all the child components to listen for events that it can later trigger.

Component Prevalence: Very Rare (I've only needed this once or twice ever)
App Prevalence: Very Rare

// parent-form.js
const EventBus = EmberObject.extend(Evented);

export Component.extend({
  init() {
    this._super(...arguments);
    this.set('eventBus', EventBus.create());
  },

  actions: {
    saveAll() {
      let event = { validationFailed: false };
      this.eventBus.trigger('saveAll', event);
      if (event.validationFailed) {
        // show error message
      }
    },

    resetAll() {
      this.eventBus.trigger('resetAll');
    }
  }
});

// child-form.js
didInsertElement() {
  this.eventBus.on('saveAll', this, this.saveFromParent);
  this.eventBus.on('resetAll', this, this.reset);
},

saveFromParent(event) {
  if (!this.save()) {
    event.validationFailed = true;
  }
},

reset() {
  // ...
}

@bendemboski
Copy link

Performance-sensitive Non-Glimmer Rendering

General use case: Rendering content assembled into a DOM fragment (rather than HTML string), where performance is critical enough that converting to an HTML string and back (i.e. rendering via glimmer/htmlSafe) is unacceptable.

My use case: I have an Ember/Electron app that is primarily an editor using content editable to generate/edit HTML content. It has a track changes window that stores a snapshot of the content and performs as-you-type diffs of the current content against the snapshot. Since it's used to edit very large documents, performance is a very significant concern. The controller runs the diff algorithm anytime the current content changes to assemble a DOM document fragment using DOM APIs for each section of the document, and those sections are bound into components that display the sections. Because of performance, the components need to use DOM APIs to put the content into this.element rather than render the content via the template as an HTML string data binding.

Component Prevalence: Very Rare
App Prevalence: Very Rare

// controller.js
onContentChanged() {
  this.set('diffDoms', this.computeDiffs());
}
{{!-- controller.hbs --}}
{{content-section label="Intro" dom=diffDoms.intro}}
{{content-section label="Abstract" dom=diffDoms.abstract}}
// content-section.js
didInsertElement() {
  this.applyDom();
},

didUpdateAttrs() {
  this.applyDom();
},

applyDom() {
  this.element.innerHTML = '';
  for (let node of [ ...this.dom.childNodes ]) {
    this.element.appendChild(node);
  }
}
{{!-- content-section.hbs --}}
{{yield}}

@bendemboski
Copy link

Out-of-Template Display State

The most common case is adding a class to the body element to support in-page CSS such as applying height: 100% or overflow: hidden to the body. The general case is modifying any state that is required to render a template correctly when that state cannot be controller from within the component/template.

Component Prevalence: Very Rare (usually only one or two general purpose components are all that's needed)
App Prevalence: Common (especially with the increased prevalence of display: flex, I think many apps use this pattern)

didInsertElement() {
  document.body.classList.add('.my-class');
},

willDestroyElement() {
  document.body.classList.remove('.my-class');
}

See https://github.com/ef4/ember-set-body-class

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