Why is it impossible to set attributes on Lit components in the boolean false state in React and what to do about it?

Aug 15, 2024

TLDR: Instead of setting the attribute to false set it to null or undefined or use property destructuring when binding strictly boolean attributes <simple-component {...(isOpen ? { "open": "" } : {})} />. I still think it is worth reading through the whole article as I have laid out some interesting edge cases when it comes to attribute binding on custom elements (web components) in React.

A couple of days ago I was creating a React component to toggle the boolean attribute open on my Lit component, but I discovered something unexpected about how React treats attributes on custom elements it is not what I was used to from normal DOM attribute binding.

This was my React component logic I had isOpen state set to false by default bound to open on my Lit component and on each button click isOpen state would be swapped to the opposite:

import { useState } from "react";
import "./simple-component.js";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);

  const handleClick = () => {
    setIsOpen(!isOpen);
  };

  return (
    <div>
      <button onClick={handleClick}>Toggle state</button>
      <simple-component open={isOpen} />
    </div>
  );
}

You would expect that when isOpen state is false there wouldn’t be attribute open at all on <simple-component> and when the state would change to true attribute open would be present on <simple-component open>

But that is not what happens attribute open is always present on <simple-component> no matter if isOpen is false or true.

Let’s look at an even more simplified React example of bounding true and false values to attribute open

export default function App() {
  return (
    <div>
      <simple-component open={false}></simple-component>
      <simple-component open={true}></simple-component>
    </div>
  );
}

It gets rendered like his in DOM:

<div>
  <simple-component open="false"></simple-component
  ><simple-component open="true"></simple-component>
</div>

But wait what is happening? I was expecting the output to be this:

<div>
  <simple-component></simple-component
  ><simple-component open></simple-component>
</div>

What is the problem? The issue is that when React is binding attributes to custom elements all attributes are treated as no non-standard attributes open and checked is the same as data-random

Binding standard attribute vs custom Attributes (like data-*, aria-*, or any non-standard attributes)

Well, I didn’t know this before myself but it turns out that when binding attributes to various DOM elements React will bind them differently depending on the attribute name. And they can be grouped into two categories custom attributes (like data-*, aria-*, or any non-standard attributes and standard attributes as being part of any DOM element specs, and standard attributes can be subgrouped into two lower categories of type boolean (checked, open, etc.) or type string (name, class, etc)

Let’s look at the examples of trying to bind various values to simple div for attributes data-test(custom attribute) vs name(standard attribute type string) vs open(standard attribute type boolean)

export default function App() {
  return (
    <div>
      <div data-test>attribute "data-test" + no value</div>
      <div data-test="string">attribute "data-test" + string</div>
      <div data-test="">attribute "data-test" + empty string</div>
      <div data-test={true}>attribute "data-test" + boolean "true"</div>
      <div data-test={false}>attribute "data-test" + boolean "true"</div>
      <div data-test={null}>attribute "data-test" + "null"</div>

      <div name>attribute "name" attribute no value</div>
      <div name="string">attribute "name" attribute + string</div>
      <div name="">attribute "name" + empty string</div>
      <div name={true}>attribute "name" + boolean "true"</div>
      <div name={false}>attribute "name" + boolean "false"</div>
      <div name={null}>attribute "name" attribute + "null"</div>

      <div open>attribute "open" no value</div>
      <div open="string">attribute "open" + string</div>
      <div open="">attribute "open" + empty string</div>
      <div open={true}>attribute "open" + boolean "true"</div>
      <div open={false}>attribute "open" + boolean "true"</div>
      <div open={null}>attribute "open" + "null"</div>
    </div>
  );
}

And rendered HTML looks like this:

<div>
  <div data-test="true">attribute "data-test" + no value</div>
  <div data-test="string">attribute "data-test" + string</div>
  <div data-test>attribute "data-test" + empty string</div>
  <div data-test="true">attribute "data-test" + boolean "true"</div>
  <div data-test="false">attribute "data-test" + boolean "true"</div>
  <div>attribute "data-test" + "null"</div>

  <div>attribute "name" attribute no value</div>
  <div name="string">attribute "name" attribute + string</div>
  <div name="">attribute "name" + empty string</div>
  <div>attribute "name" + boolean "true"</div>
  <div>attribute "name" + boolean "false"</div>
  <div>attribute "name" attribute + "null"</div>

  <div open>attribute "open" no value</div>
  <div open>attribute "open" + string</div>
  <div>attribute "open" + empty string</div>
  <div open>attribute "open" + boolean "true"</div>
  <div>attribute "open" + boolean "true"</div>
  <div>attribute "open" + "null"</div>
</div>

See how much difference there is when assigning the same values to attribute data-test vs name vs open. However, it does make sense because open is a boolean attribute so React allows only boolean binding on it, and name is a string attribute so React allows only string binding on it.

But what is more interesting is when it comes to the custom attributes data-test in the example above, they are treated neither like open or name standard attributes but kind of a mix of both, and as a result almost always value is present on the attribute.

Implications on custom elements (web components)

When it comes to custom elements React will treat any attribute binding as it does for custom attribute data-test in the example above. That means when we try to bind standard attributes of boolean or string type to a custom element (web component) the rendered outcome will be as binding to custom attributes.

Let’s look at the same example from above but now binding attributes to the <simple-component> custom element instead of div

export default function App() {
  return (
    <div>
      <simple-component data-test>
        attribute "data-test" + no value
      </simple-component>
      <simple-component data-test="string">
        attribute "data-test" + string
      </simple-component>
      <simple-component data-test="">
        attribute "data-test" + empty string
      </simple-component>
      <simple-component data-test={true}>
        attribute "data-test" + boolean "true"
      </simple-component>
      <simple-component data-test={false}>
        attribute "data-test" + boolean "true"
      </simple-component>
      <simple-component data-test={null}>
        attribute "data-test" + "null"
      </simple-component>
      <!--...-->
      <simple-component name>
        attribute "name" attribute no value
      </simple-component>
      <simple-component name="string">
        attribute "name" attribute + string
      </simple-component>
      <simple-component name="">
        attribute "name" + empty string
      </simple-component>
      <simple-component name={true}>
        attribute "name" + boolean "true"
      </simple-component>
      <simple-component name={false}>
        attribute "name" + boolean "false"
      </simple-component>
      <simple-component name={null}>
        attribute "name" attribute + "null"
      </simple-component>
      <!--...-->
      <simple-component open>attribute "open" no value</simple-component>
      <simple-component open="string">
        attribute "open" + string
      </simple-component>
      <simple-component open="">
        attribute "open" + empty string
      </simple-component>
      <simple-component open={true}>
        attribute "open" + boolean "true"
      </simple-component>
      <simple-component open={false}>
        attribute "open" + boolean "true"
      </simple-component>
      <simple-component open={null}>attribute "open" + "null"</simple-component>
    </div>
  );
}

As we can see there is no change now between data-test vs name open as it was previously when binding div normal DOM element

<div>
  <simple-component data-test="true"
    >attribute "data-test" + no value</simple-component
  ><simple-component data-test="string"
    >attribute "data-test" + string</simple-component
  ><simple-component data-test=""
    >attribute "data-test" + empty string</simple-component
  ><simple-component data-test="true"
    >attribute "data-test" + boolean "true"</simple-component
  ><simple-component data-test="false"
    >attribute "data-test" + boolean "true"</simple-component
  ><simple-component>attribute "data-test" + "null"</simple-component>
  <!--...-->
  <simple-component name="true"
    >attribute "name" attribute no value</simple-component
  ><simple-component name="string"
    >attribute "name" attribute + string</simple-component
  ><simple-component name="">attribute "name" + empty string</simple-component
  ><simple-component name="true"
    >attribute "name" + boolean "true"</simple-component
  ><simple-component name="false"
    >attribute "name" + boolean "false"</simple-component
  ><simple-component>attribute "name" attribute + "null"</simple-component>
  <!--...-->
  <simple-component open="true">attribute "open" no value</simple-component
  ><simple-component open="string">attribute "open" + string</simple-component
  ><simple-component open="">attribute "open" + empty string</simple-component
  ><simple-component open="true"
    >attribute "open" + boolean "true"</simple-component
  ><simple-component open="false"
    >attribute "open" + boolean "true"</simple-component
  ><simple-component>attribute "open" + "null"</simple-component>
</div>

So what to do? how to bound attributes to custom elements and not to have unexpected results?

We can observe from the example above that it is almost impossible to set an attribute into a boolean “false” state as you would naturally expect in React (where it toggles the name of the attribute depending on the boolean state)

export default function App() {
  return (
    <simple-component open={false}></simple-component>
    <simple-component open={true}></simple-component>
  );
}

HTML outcome is this

<simple-component data-test="false"></simple-component>
<simple-component data-test="true"></simple-component>

Not too good because what if want to toggle my custom element in the open or closed state, this is not going to work because Rect will always add an attribute with a value of a string of length more than zero so our custom element will always be in open state inside or web component.

The Solution

Fortunately, React provides an escape hatch in the form of null and undefined, when we set the attribute as null or undefined we get the desired outcome of not having an attribute present on the DOM element

export default function App() {
  return (
    <div>
      <simple-component open={null}>"null"</simple-component>
      <simple-component open={undefined}>"undefined"</simple-component>
      <simple-component open={false}>"false"</simple-component>
    </div>
  );
}

Note how false keeps the attribute but null and undefined removes it

<div>
  <simple-component>"null"</simple-component>
  <simple-component>"undefined"</simple-component>
  <simple-component open="false">"false"</simple-component>
</div>

This would work but we have to adjust fuction logic for toggling isOpen state from the previous examples, instead of doing a simple boolean swap we need to do a ternary or if statement

import { useState } from "react";
import "./simple-component.js";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);

  const handleClick = () => {
    setIsOpen(isOpen ? null : true);
  };

  return (
    <div>
      <button onClick={handleClick}>Toggle state</button>
      <simple-component open={isOpen} />
    </div>
  );
}

It is not that bad because there was not much additional code added but, it feels like we have added this extra point of logic that could become the cause of a bug in the future because from the code itself, it is hard to reason why was this ternary statement added there and I can even see how in the future someone could look at this code and make a quick edit to change ternary statement back to setIsOpen(isOpen) thinking maybe the original author overcomplicated here.

Also maybe I’m being too nitpicking but another minor issue is when we set the attribute to true the attribute also has a value of string open="true" not just empty value attribute open=""

export default function App() {
  return (
    <div>
      <simple-component open={true}>"true"</simple-component>
    </div>
  );
}

It should be only <simple-component open>

<div>
  <simple-component open="true">"true"</simple-component>
</div>

It is not a problem necessarily and it shouldn’t be a problem in the vast majority of cases, but it could be a cause of potential issues when it comes to setting boolean attributes and determining if the attribute is boolean with the getAttribute method. The getAttribute("open") would return the true value of string "true" where it is common practice in web development to check for null from getAttribute if the attribute is not present and check for the empty string "" where attribute is present so it is quite possible that additional check of truly string would not be made by someone to determine if the attribute is present and be a cause of a bug. Also, I find it personally visually not pleasing having a boolean attribute with trailing “true”

Another solution

Using property destructuring could be a good solution for both problems I described above, unfortunately, it is terrible to look at but it allows us to go back to the original boolean toggle function without ternary statement:

import { useState } from "react";
import "./simple-component.js";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);

  const handleClick = () => {
    setIsOpen(!isOpen);
  };

  return (
    <div>
      <button onClick={handleClick}>Toggle state is: {isOpen}</button>
      <simple-component {...(isOpen ? { open: "" } : {})} />
    </div>
  );
}

And the presence of the attribute is not prefixed with the value of string "true"

<!-- isOpen === false -->
<div>
  <button>Toggle state is: false</button>
  <simple-component></simple-component>
</div>

<!-- isOpen === true -->
<div>
  <button>Toggle state is: true</button>
  <simple-component open></simple-component>
</div>

Although I find using property destructuring ugly, as a result our code now is more explicit about which issue we are solving with property destructuring than in the case of toggling the isOpen state with the ternary statement. It would be harder to imagine that in the future someone would think there probably is no reason behind property destructuring or at least is more explicit about the problem being solved than in the case of the ternary boolean toggle function.