javascripthtmlcssreactjsstyled-components

Custom props not being passed through a React component with extended styles


I have this Button.jsx file which exports a <Button /> component, that wraps a <StyledButton /> component.

(I need this wrap to handle extra props like loading and spinner)

The <StyledButton /> component checks for a $hasIcon prop to fix the width based on an existing icon in the button:

// Button.jsx

import styled from 'styled-components';

const StyledButton = styled.button`
    width: ${ ({ $hasIcon }) => $hasIcon ? 'fit-content' : '100%' };
    background-color: red;
`;

export const Button = ({ loading, spinner, children, ...props }) => (
    <StyledButton { ...props }>
        { children }
        { loading ? spinner : null }
    </StyledButton>
);

Then, I use the <Button /> component from Button.jsx in my whole project without problems, but things start getting weird when I extend its styles to overwrite some of them.

// SomeComponent.jsx

import styled from 'styled-components';
import Button from '@components/Button';
import { SomeIcon } from '@icons';

const ExtendedStyleButton = styled(Button)`
    background-color: green;
`;

export const SomeComponent = () => (
    <ExtendedStyleButton $hasIcon>
        <SomeIcon />
    </ExtendedStyleButton>
);

Expected Behavior

$hasIcon prop passed to <ExtendedStyleButton /> should reach <StyledButton /> through the extended style of <Button /> in order to fix the width.

Desired path:

      $hasIcon
          |
          ▼
          
<ExtendedStyleButton /> 

          |  $hasIcon
          ▼

      <Button />

          |  $hasIcon
          ▼

   <StyledButton /> // fixed width to fit-content due to $hasIcon prop correctly passed

Actual Behavior

I have been debugging everything with console.log, and I believe the $hasIcon prop goes up to <ExtendedStyleButton /> and nothing else, which causes the width not being fixed for an icon and taking a 100% value.

Actual path:

      $hasIcon
          |
          ▼
          
<ExtendedStyleButton /> 

          |
          ▼

      <Button />

          | 
          ▼

   <StyledButton /> // fixed width to 100% because $hasIcon prop has not been received

Solution

  • The $ (dollar) prefix character on props is interpreted by styled-components as a sign for transient props:

    If you want to prevent props meant to be consumed by styled components from being passed to the underlying React node or rendered to the DOM element, you can prefix the prop name with a dollar sign ($), turning it into a transient prop.

    That is why your <ExtendedStyleButton> does not forward your $hasIcon prop to its underlying styled <Button>.

    With version 5 of styled-components, if you can change your API, the simplest solution would be to rename your $hasIcon prop to remove the $ prefix, e.g. into just hasIcon. That way, styled-components no longer treats it as a transient prop, and happily forwards it to the underlying Component. Of course, you can still use it to do dynamic styling:

    const StyledButton = styled.button`
        width: ${ ({ hasIcon }) => hasIcon ? 'fit-content' : '100%' };
        background-color: red;
    `;
    
    export const Button = /* unchanged */
    
    const ExtendedStyleButton = /* unchanged */
    
    export const SomeComponent = () => (
        <ExtendedStyleButton hasIcon>
            <SomeIcon />
        </ExtendedStyleButton>
    );
    

    Styled-components still makes sure not to forward it to the base <button> however, because it knows <button> is a raw DOM Element (not a React Custom Component), and it is not accepting attributes named "hasIcon":

    The styled function is smart enough to filter non-standard attributes automatically for you.

    Unfortunately, from version 6 of styled-components, this "smart enough" feature is no longer built-in:

    If haven't migrated your styling to use transient props ($prefix), you might notice React warnings about styling props getting through to the DOM in v6. To restore the v5 behavior, use StyleSheetManager

    import isPropValid from '@emotion/is-prop-valid';
    import { StyleSheetManager } from 'styled-components';
    
    
    function MyApp() {
        return (
            <StyleSheetManager shouldForwardProp={shouldForwardProp}>
                {/* Rest of your App content */}
            </StyleSheetManager>
        )
    }
    
    
    // This implements the default behavior from styled-components v5
    function shouldForwardProp(propName, target) {
        if (typeof target === "string") {
            // For HTML elements, forward the prop if it is a valid HTML attribute
            return isPropValid(propName);
        }
        // For other elements, forward all props
        return true;
    }
    

    Live demo: https://stackblitz.com/edit/vitejs-vite-d3xmzx

    Of course, you still need to make sure to use custom prop names that do not happen to be standard attribute names (like fill, src, etc.), otherwise styled-components would stillpass them to the DOM Elements. An easy bullet-proof solution to avoid such collisions is to use a prefix, e.g. styleHasIcon, or even just an underscore _hasIcon