Skip to the content.

Additional Custom Element Lifecycle

(Work in progress. Want to add notes for constructor and others. Add details to Updates. Outline how component creation makes a difference: createElement vs. tag-in-template. Also map to vue or react lifecycles. )

Adding data to components in a secure manner is the primary goal of this pattern. The ideal place to add data to a component is after the constructor and before the connectedCallback.

Holistic runtime view of extended lifecycle

Built-in lifecycle.

For a recap here is the built in lifecycle for ES6 custom elements

Extended Lifecycle in connectedCallback

Code first. Explanation after.

Example 1 - the customElement

export class UserDetailsEle extends HTMLDivElement{
constructor(){
  // Empty user data. Parent adds the data using createElement example below.
  // When the component needs to fetch it's own data, that should be in the connectedCallback. Example provided later in the document.
  this.user = {
    firstName:'',
    lastName:''
   }
}

connectedCallback(){
  // pre-template-lit
  const frag = this.templateLit()
  
  // pre-paint
  this.abortController = new AbortController() // this needs to be in the connectedCallback, do not create in the constructor.
  this.button = frag.querySelector(':scope button')
  this.button.addEventListener('click',()=>{ // do something interesting },signal:this.abortController.signal)
  
  this.append(frag)
  
  // post-paint - nothing to do in this example
}

disconnectedCallback(){
  this.abortController.abort('disconnected')
  this.user = null // can optionally clear out any large data sets. This is not large and I probbaly wouldn't bother cleaning it up in a real component.
}

templateLit(){
   // html tagged template returns a document fragment that is not added to the DOM yet.
   return html`<div>
      <div>${this.user.firstName} ${this.user.lastName}</div>
      <div><button>Ok</div>
     </div>`
}

loadingTemplateLit(){
  return html`<div>Loading</div>`
}
}

Example 1 Parent initializing the component and adding the data.

import {createElement} from '....somewhere'
// see my blog on my custom createElement function //todo: put a link here
 const userDetails = createElement({
    tagName:'div',
    attributes:{
      is:'user-details'
    }
    properties:{
       user:{ 
        firstName:'Jane',
        lastName:'Doe'
    }
 })

this.append(userDetails)

The outline below is more of a thought process than named callbacks or hooks.

I might later get rid of post paint and go straight to updates, even though updates are not part of the connectedCallback.

pre-template-lit

Get things ready to turn the template into a document fragment.

pre-paint

The template is now a document fragment. Before painting, attach events and do any other work so when the fragment is appended/painted to the live-DOM it is ready for the user/script/ or other customElements to interact with it. Other elements maybe using mutationObserver to wait for the paint then do work. The whole point is to avoid adding html to the live-DOM too early and then trying to compensate.

post-paint

I can’t think of anything that would go here. This is more conceptual, other components might interact with the component post paint via using mutation observers or maybe thrown events. Note: I have not had the issue of other components interacting with my components too early using this approach. I do NOT recommend throwing an event like ‘is-painted’. If everything is setup before paint, throwing events is just extra work. This is not to say never throw events. Just I’ve never needed an event thrown to tell me the item was painted on the page. I have a mutation observer that can do this so in the off chance I would need to know if an component was added to the DOM, it is the concern of the component that cares and I don’t need to bake extra work into each component.

Example 2 - multiple lifecycles in the connectedCallback

...

connectedCallback(){
  // pre-template-lit 
  const loadingFrag = this.loadingTemplateLit();
  // pre-paint 
  this.append(loadingFrag);
  // post-paint
  
  // pre-template-lit
  this.user = await this.getUserInfo();//fetch - could be long running so we show loading screen.
  const frag = this.templateLit()
  // pre-paint
  this.abortController = new AbortController()
  this.button = frag.querySelector(':scope button')
  this.button.addEventListener('click',()=>{ // do something interesting },signal:this.abortController.signal)
  
  this.append(frag) // overwrites the loading templatelit
  
  // post-paint - still nothing to do here.
}

disconnectedCallback(){
  this.abortController.abort('disconnected')
  this.user = null // can optionally clear out any large data sets. This is not large and I probbaly wouldn't bother cleaning it up in a real component.
}

templateLit(){
   // html tagged template returns a document fragment that is not added to the DOM yet.
   return html`<div>
      <div>${this.user.firstName} ${this.user.lastName}</div>
      <div><button>Ok</div>
     </div>`
}

loadingTemplateLit(){
  return html`<div>Loading</div>`
}

Reactivity

When adopting this approach, I found that I do not need or use reactivity most of the time. When using vue,react,angular, since reactivity is there and cheap, it can easily be over used.

For the sake of this article, reactivity happens after the element is painted on the DOM, and things need to change.

Reactivity happens either:

Note on modifying an attribute: The value of the attribute route is low. Chances are you are modifying the attribute using JavaScript. Modifying an attribute is about the same amount of code as just calling an update function. But there is a big difference between using attributes to set values before an element is rendered to the page and after. Using attributes to set initial values does not need to use attributeChangedCallback.

Reactivity via a update function

(Technically not reactivity or ‘data driven reactivity’, but achieves the same goal, making changes to the DOM. In a true reactive system we would change the data and the UI would update.)


connectedCallback(){ 
 ... 
  const mainFrag = this.mainTemplateLit()
  // strive to only query once
  this.userDetailsContainer = mainFrag.querySelector('.user-details')
  userDetialsContainer.append(this.userDetailsLit())
  this.append(mainFrag)
}

/*
* I repaint the child DOM unless I think there will be
*  events attached to it by outer elements.
*  Input elements and other from elements I might not re-paint. 
*  I would instead just update the values in the elements.
*/
updateUserDetails(firstName, lastName){
  this.user.firstName = firstName
  this.user.lastName = lastName
  this.userDetailsContainer.replaceChildren()
  this.userDetailsContainer.append(this.userDetailsLit())
}

mainTemplateLit(){
  return html`<div>
    <h1>Hello</h1>
    <div class='user-details'></div>
  </div>`
}


userDetailsLit(){
  return html`<span>${this.user.firstName} ${this.user.lastName}</span>`
}

Reactive component being used

import {createElement} from '....somewhere'
// see my blog on my custom createElement function //todo: put a link here
const userDetails = createElement({ // same as the first usage example in this article})
this.append(userDetails)
// something interesting happens, maybe an event like a click
btnClickEventHandler(e){
   userDetails.update('Jane', 'Doe') // or this could be designed to take a user object.
}