Why you should use ifDefined helper when binding attributes in a Lit component templates

Aug 29, 2024

I have this simple component, with one reactive property name, and the value of that reactive property is bound to the name attribute on a div in render template:

class MyComponent extends LitElement {
  static properties = {
    name: {},
  };

  constructor() {
    super();
    this.name = "Rob";
  }

  render() {
    return html`<div name=${this.name}></div>`;
  }
}

It gets rendered to this in DOM:

<my-component>
  <div name="Rob"></div>
</my-component>

Now let’s look at what will happen if I adjust my Lit component class to not initialize the name property, so I will remove constructor method:

class MyComponent extends LitElement {
  static properties = {
    name: {},
  };

  render() {
    return html`<div name=${this.name}></div>`;
  }
}

Or in TypeScript:

@customElement("my-component")
export class MyComponent extends LitElement {
  @property() name?: string;

  override render() {
    return html` <div name=${this.name}></div>`;
  }
}

Now if I look at the rendered HTML:

<my-component>
  <div name></div>
</my-component>

You see how there is this lingering empty attribute present on the div because we didn’t initiate reactive property name and there was no attribute added on DOM node so effectively the value of the property is undefined.

This is not a problem in itself, but imagine if instead of rendering a regular div element we are rendering another Lit component and binding name attribute to it:

class MyComponent extends LitElement {
  static properties = {
    name: {},
  };

  render() {
    return html`<another-lit-component
      name=${this.name}
    ></another-lit-component>`;
  }
}

and rendered HTML:

<my-component>
  <another-lit-component name></another-lit-component>
</my-component>

This is also not necessarily a problem but it could be a potential cause of something unintended, because in a form like this: <another-lit-component name> the reactive property if exist inside <another-lit-component> will be set to empty string "" but my parent component <my-component> property value at this stage is undefined, now, of course, they both are falsely values and in most cases, it is not an issue but it could become one in some specific scenario.

Also when I’m working in Typescript there is a huge difference between undefined or empty string "" and I would like to keep it that way, when passing property value through multiple levels of Lit components I want to have consistent property state if parent property is undefined and I’m passing it down to child component the child component property should also be undefined not empty string "".

I tried a couple of things that didn’t work to see if it could be solved.

I tried ternary statement and giving falsely value:

class MyComponent extends LitElement {
  static properties = {
    name: {},
  };

  render() {
    return html`<div name="${this.name ? this.name : undefined}"></div>`;
  }
}

But of course, it doesn’t work why would it if it didn’t work before, it is effectively the same value as before undefined but I just wanted to be sure that I’m really passing there undefined.

Then I tried ternary statement but to render the whole attribute with value if true and nothing if false:

class MyComponent extends LitElement {
  static properties = {
    name: {},
  };

  render() {
    return html` <div ${this.name ? html`name=${this.name}` : nothing}></div>`;
  }
}

It looked like it was working when I saw rendered HTML the attribute was not rendered:

<my-component>
  <div></div>
</my-component>

Hurray! I thought but when I tried to set the attribute it was also not rendered:

<my-component name="Jay">
  <div></div>
</my-component>

I discovered that when I do attribute binding in a ternary way like this:

  render() {
    return html`
    <div
	    ${this.name ? html`name=${this.name}` : nothing}
	>
	</div>`;
  }

Somehow the binding of the attribute is lost forever even if the value is set later.

Then I also tried adding a question mark before the attribute this is usually how boolean-type attributes are attached templates but I wanted to see what would happen with non-boolean values:

class MyComponent extends LitElement {
  static properties = {
    name: {},
  };

  render() {
    return html`<div ?name=${this.name}></div>`;
  }
}

Well, it started to act as a boolean attribute, if the name property is falsely (empty string, null or undefined) attribute indeed is gone:

<my-component>
  <div></div>
</my-component>

But if I add value to the property that is not falsely by setting attribute name to something <my-component name="Jay">:

<my-component name="Jay">
  <div name></div>
</my-component>

The attribute name is added but without value, it is acting as a boolean type but no wonder because that is what adding a question mark to the beginning of an attribute does.

This could be an acceptable solution maybe in a case of binding to a simple div element but imagine again if instead of rendering normal div I were to render another lit component and want to pass the value of the name to it:

<my-component name="Jay">
    <another-lit-component name></<another-lit-component name>>
</my-component>

Now this is even worse than in the previous example because now effectively we have lost the value of the property completely, so the child component would receive empty string "" instead of "Jay". That is definitely something that will cause problems.

I was losing all hope to find a solution at this point but then I discovered ifDefined helper function that you can use when binding attributes in your Lit templates. And what it does is exactly what I needed, if the attribute value is null or undefined the attribute is removed completely but if the value is something else it is added notice how it treats empty string "", an empty string is treated as the value being present so the attribute is added.

Let’s have a look at the following example I have a component with two properties name and occupation I bind the property name to the attribute name with ifDefined helper and bind occupation to property occupation in regular way:

import { LitElement } from "lit";
import { html } from "lit/static-html.js";
import { ifDefined } from "lit/directives/if-defined.js";

class MyComponent extends LitElement {
  static properties = {
    name: {},
    occupation: {},
  };

  render() {
    return html`<div
      name="${ifDefined(this.name || undefined)}"
      occupation="${this.occupation}"
    ></div>`;
  }
}

customElements.define("my-component", MyComponent);

Note that both property values are not initialized and are effectively undefined, so when I render the component this is the HTML:

<my-component>
  <div occupation></div>
</my-component>

You see how the occupation attribute is lingering around and is not name because of ifDefined helper, and if I render with both attributes set <my-component name="Jay" occupation="Coder">:

<my-component name="Jay" occupation="Coder">
  <div name="Jay" occupation="Coder"></div>
</my-component>

Both attributes are working normally now.

Because in the Lit components I build I quite heavily use other Lit components and sometimes it can go 4 levels deep and parents are passing values to children as a state, I find ifDefined helper to be a crucial part of binding attributes. I use it by default if my properties are not initialized to a value.