A long-winded answer to solving the following error:
Warning: Received `true` for non-boolean attribute `gray`. If this is expected, cast the value to a string.
Or perhaps more accurately, why the styled
API is kinda flawed.
For the purpose of this doc, a React component can have two different types of props:
styled
API (e.g. styled-components).
When these two start to merge, problems start to appear.The error above is fairly simple to explain, but hard to solve. A prop is being passed from a call site, and ultimately rendered to the DOM as an attribute. If the prop (and thus attribute) has a boolean value, the error will appear. Note that if the prop has a non-boolean value, it will still write to the DOM, but will not throw an error. This is actually a React error, but using Styled Components and mixing behaviour and styling props makes it more likely to happen.
This is explained by the intended behaviour of Styled Components.
This component can take any prop. It passes it on to the HTML node if it's a valid attribute, otherwise it only passes it into interpolated functions.
Note: if you style a standard tag (like
<h1>
in above example), styled-components will not pass the custom props (to avoid the Unknown Prop Warning).However, it will pass all of them to a custom React component https://styled-components.com/docs/api#using-custom-props
This means that a valid attribute (e.g. color
) will be passed to the DOM, even if the component is a direct Styled Component of a DOM element, e.g. styled.p
. Note that for attributes like color
, this will not throw an error. The attribute is valid, and the value is not a boolean. However, it will still be written to the DOM, which is usually not wanted.
Note that Emotion is similar - it uses a magic whitelist that blocks some props from making it to the DOM. However, it seems (needs checking) to only write valid HTML attributes for string tags:
By default, Emotion passes all props (except for theme) to custom components and only props that are valid html attributes for string tags
This seems to be the core problem. Using the styled
API in Styled Components (and Emotion) will seemingly always lead to confusing, and often incorrect behaviour. Once behaviour props and styling props are combined, they have to be split up again. Otherwise the mish-mash of props are passed to every component in the tree, and then need to magically be separated at the right time.
const BaseStyledComponent = styled.div` padding: 4px; ${({ color }) => `color: ${color};`} ${({ active }) => active && `border: 1px solid black;`} `; const WrappedStyledComponent = styled(BaseStyledComponent)` border: 1px solid red; `; <BaseStyledComponent active color="red" xHeight="4" aria-hidden="true" bar={true}> BaseStyledComponent </BaseStyledComponent> <WrappedStyledComponent active color="red" xHeight="4" aria-hidden="true" bar={true}> WrappedStyledComponent </WrappedStyledComponent>
In the above use, the base level component is a SC component wrapping a standard DOM element. Thus, both components have the following behaviour:
defaultValidatorFn
(more on this later), to omit invalid attributes being passed to the DOMcolor
, xHeight
, and aria-hidden
) to the DOMactive
to the DOM, as it's not a valid attributeactive
is read in the styling blockThis is an okay result. Our props are passing through, we're using them as we want, and we're getting no errors. However, we are putting the color
attribute on the DOM, as it's considered valid. If for some reason we had a valid attribute name as a styling prop AND it was boolean, it would throw an error.
A possible solution is to use transient props. If we make $color
a transient prop, it will no longer pass through to the DOM. It stops at this styled component.
const BaseStyledComponent = styled.div` padding: 4px; ${({ $color }) => `color: ${$color}`} `; <BaseStyledComponent $color="red"> BaseStyledComponent </BaseStyledComponent>
Since we're using the Styled Components component directly, we also have to update the call site, which may be a non-starter. If you had a wrapping component, the changes may be encapsulated in that component:
const InternalStyledComponent = ({ color }) => <BaseStyledComponent $color={color} />;
Note that it's somewhat common to export low level primitives as a Styled Component directly. Thus creating two different APIs in your codebase.
Another solution is shouldForwardProp. We can re-write the Styled Components declaration and explicitly tell it to not forward the prop to not be forwarded on. In this case, we're preventing it from being forwarded to the DOM. Like transient props, this will also prevent it being passed to any lower components in the tree.
const BaseStyledComponent = styled.div.withConfig({ shouldForwardProp: (prop, defaultValidatorFn) => !['color'].includes(prop) && defaultValidatorFn(prop), })` padding: 4px; ${({ color }) => `color: ${color}`} `;
This solution allows us to keep our component calls the same - we don't have to use $color
in call sites.
Another core issue is spreading props to the DOM. I'm still torn if if this is a good idea in general. It's useful for low level components that want to use aria
props or similar, e.g. Checkbox. However it does lead to issues with traceability.
const BaseComponentSpread = (props) => <div {...props} />;
const BaseComponentNoSpread = ({ className, children }) => (
<div className={className}>{children}</div>
);
const WrappedBaseComponentSpread = styled(BaseComponentSpread)`
padding: 4px;
${({ color }) => `color: ${color};`}
${({ active }) => active && `border: 1px solid black;`}
`;
const WrappedBaseComponentNoSpread = styled(BaseComponentNoSpread)`
padding: 4px;
${({ color }) => `color: ${color};`}
${({ active }) => active && `border: 1px solid black;`}
`;
<WrappedBaseComponentSpread active color="red" xHeight="4" aria-hidden="true" bar="1">
WrappedBaseComponentSpread
</WrappedBaseComponentSpread>
<WrappedBaseComponentNoSpread active color="red" xHeight="4" aria-hidden="true" bar="1">
WrappedBaseComponentNoSpread
</WrappedBaseComponentNoSpread>
The output of these two components are quite different. Both components render their styles appropriately.
The first component (the one that spreads props):
color
, xHeight
, and aria-hidden
to the DOMbar
) to the DOMactive
The other component:Both have their own problems. Adding the appropriate aria-*
props to the latter requires a fair bit of clutter, depending on the permutations needed. However it outputs the most desirable DOM, and still works with the styling (as we're explicitly passing className
).
Assuming that it's not feasible to remove prop spreading, we're back to transient props and shouldForwardProp.
Note: I believe there's a subtle difference between a component using Styled Components internally, vs wrapping the entire component in styled
directly. In theory, the former would be closer to the first use case in this doc, where magic omission is performed, as we're wrapping a DOM element. The latter wouldn't do any omission by design.
As there's an existing component being wrapped in Styled Components, this API can be useful to keep the styling encapsulated.
const BaseComponentSpread = (props) => <div {...props} />;
const WrappedBaseComponentSpread = styled(BaseComponentSpread)`
// `color` is not passed to BaseComponentSpread
${({ $color }) => `color: ${$color}`}
`;
Updating the Styled Components definition once again fixes our problem:
const WrappedBaseComponentSpread = styled(BaseComponentSpread).withConfig({ shouldForwardProp: (prop, defaultValidatorFn) => // Warning, defaultValidatorFn probably shouldn't be used here. !['color'].includes(prop) && defaultValidatorFn(prop), })` border: 1px solid red; ${({ color }) => `color: ${color}`} `;
As we're using defaultValidatorFn
, this is actually doing two things:
color
from being passed down to BaseComponentSpread
BaseComponentSpread
.However, this can cause issues. Now bar
isn't being passed to lower level components as they aren't valid HTML attributes. Thus each component in the tree must only block the styling-only components, and no more. To do this, we shouldn't use defaultValidatorFn
.
Note that keeping the styles encapsulated to the component is important. We don't want to add unnecessary [[coupling]] by omitting props (e.g. bar
) only on the lowest level component. BaseComponentSpread
may have no knowledge of bar
, and thus shouldn't be responsible for removing it from the DOM.
In theory the lowest level component could use defaultValidatorFn
in combination with spreading props to prevent unnecessary DOM additions and errors. However, the lowest level component isn't always a Styled Components component, nor is it able to remove the spread of props. Then we're a bit stuck.
If you control everything used Styled Components, this actually is possible. Given a Button that just spreads props, and a StyledButton, you could prevent the forwarding at the StyledButton level. That way, no excess props are ever added. However as soon as there's one component outside of your purview, problematic props are re-added. Props passed to StyledStyledButton will get passed all the way down, unless explicitly ignored at some level.
There's an insidious issue here that is hard to solve. As soon as behaviour props and styling props are mixed together, we have to figure out some smart way to separate them. On top of that, these props can be introduced at any level and muddy up the entire tree.
I think the key answer here is the css prop. By styling components this way, we avoid mixing behaviour and styling props entirely.
For this to work, you have to avoid the styled
API altogether. Any use of it will mix the two types of props back together. Assuming you're then spreading props onto the DOM, the issue comes back. In terms of a design system, this might be an okay compromise. Once users start wrapping your components in styled
, it's up to them to avoid passing unnecessary props and attributes to the DOM.
In essence, this is similar to the transient props API, but is a slightly more agnostic way of solving the problem. To me, this feels like a root solve that works with other CSS libraries, and a bit more 'React'. Additionally, it may help with other things like [[20221024113715-css-in-js-build-runtime]]. I don't think you'd be able to use an exported Styled Components component directly either, but that may not be a bad thing.
Alternatively, if you can avoid spreading props in your components, that can also mitigate this problem.
styled
on a standard DOM element performs some magic omission of non-valid attributesstyled
on a React component does not perform this magichttps://github.com/emotion-js/emotion/issues/2193
#blog