Why is it impossible to set attributes on Lit components in the boolean false state in React and what to do about it?
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.