React component as prop: the right way™️
Originally published at https://www.developerway.com. The website has more articles like this 😉
As always in React, there is one million way to do exactly the same thing. If, for example, I need to pass a component as a prop to another component, how should I do this? If I search the popular open-source libraries for an answer, I will find that:
- I can pass them as Elements like Material UI library does in Buttons with the
startIcon
prop - I can pass them as components themselves like for example react-select library does for its
components
prop - I can pass them as functions like Material UI Data Grid component does with its
renderCell
prop
Not confusing at all 😅.
So which way is the best way and which one should be avoided? Which one should be included in some “React best practices” list and why? Let’s figure it out together!
Or, if you like spoilers, just scroll to the summary part of the article. There is a definitive answer to those questions 😉
Why would we want to pass components as props?
Before jumping into coding, let’s first understand why we would want to pass components as props to begin with. Short answer: for flexibility and to simplify sharing data between those components.
Imagine, for example, we’re implementing a button with an icon. We could, of course, implement it like this:
const Button = ({ children }: { children: ReactNode }) => {
return (
<button>
<SomeIcon size="small" color="red" />
{children}
</button>
);
};
But what if we need to give people the ability to change that icon? We could introduce iconName
prop for that:
type Icons = 'cross' | 'warning' | ... // all the supported icons
const getIconFromName = (iconName: Icons) => {
switch (iconName) {
case 'cross':
return <CrossIcon size="small" color="red" />;
...
// all other supported icons
}
}
const Button = ({ children, iconName }: { children: ReactNode, iconName: Icons }) => {
const icon = getIconFromName(name);
return <button>
{icon}
{children}
</button>
}
What about the ability for people to change the appearance of that icon? Change its size and color for example? We’d have to introduce some props for that as well:
type Icons = 'cross' | 'warning' | ... // all the supported icons
type IconProps = {
size: 'small' | 'medium' | 'large',
color: string
};
const getIconFromName = (iconName: Icons, iconProps: IconProps) => {
switch (iconName) {
case 'cross':
return <CrossIcon {...iconProps} />;
...
// all other supported icons
}
}
const Button = ({ children, iconName, iconProps }: { children: ReactNode, iconName: Icons, iconProps: IconProps }) => {
const icon = getIconFromName(name, iconProps);
return <button>
{icon}
{children}
</button>
}
What about giving people the ability to change the icon when something in the button changes? If a button is hovered, for example, and I want to change icon’s color to something different. I’m not even going to implement it here, it’d be way too complicated: we’d have to expose onHover
callback, introduce state management in every single parent component, set state when the button is hovered, etc, etc.
It’s not only a very limited and complicated API. We also forced our Button
component to know about every icon it can render, which means the bundled js of this Button
will not only include its own code, but also every single icon on the list. That is going to be one heavy button 🙂
This is where passing components in props come in handy. Instead of passing to the Button
the detailed limited description of the Icon
in form of its name and its props, our Button
can just say: "gimme an Icon
, I don't care which one, your choice, and I'll render it in the right place".
Let’s see how it can be done with the three patterns we identified at the beginning:
- passing as an Element
- passing as a Component
- passing as a Function
Building a button with an icon
Or, to be precise, let’s build three buttons, with 3 different APIs for passing the icon, and then compare them. Hopefully, it will be obvious which one is better in the end. For the icon we’re going to use one of the icons from material ui components library. Lets start with the basics and just build the API first.
First: icon as React Element
We just need to pass an element to the icon
prop of the button and then render that icon near the children like any other element.
type ButtonProps = {
children: ReactNode;
icon: ReactElement<IconProps>;
};
export const ButtonWithIconElement = ({ children, icon }: ButtonProps) => {
return (
<button>
// our icon, same as children, is just React element
// which we can add directly to the render function
{icon}
{children}
</button>
);
};
And then can use it like this:
<ButtonWithIconElement icon={<AccessAlarmIconGoogle />}>button here</ButtonWithIconElement>
Second: icon as a Component
We need to create a prop that starts with a capital letter to signal it’s a component, and then render that component from props like any other component.
type ButtonProps = {
children: ReactNode;
Icon: ComponentType<IconProps>;
};
export const ButtonWithIconComponent = ({ children, Icon }: ButtonProps) => {
return (
<button>
// our button is a component
// its name starts with a capital letter to signal that
// so we can just render it here as any other component
<Icon />
{children}
</button>
);
};
And then can use it like this:
import AccessAlarmIconGoogle from '@mui/icons-material/AccessAlarm';
<ButtonWithIconComponent Icon={AccessAlarmIconGoogle}>button here</ButtonWithIconComponent>;
Third: icon as a function
We need to create a prop that starts with render
to indicate it's a render function, i.e. a function that returns an element, call the function inside the button and add the result to component's render function as any other element.
type ButtonProps = {
children: ReactNode;
renderIcon: () => ReactElement<IconProps>;
};
export const ButtonWithIconRenderFunc = ({ children, renderIcon }: ButtonProps) => {
// getting the Element from the function
const icon = renderIcon();
return (
<button>
// adding element like any other element here
{icon}
{children}
</button>
);
};
And then use it like this:
<ButtonWithIconRenderFunc renderIcon={() => <AccessAlarmIconGoogle />}>button here</ButtonWithIconRenderFunc>
That was easy! Now our buttons can render any icon in that special icon slot without even knowing what’s there. See the working example in the codesandbox.
Time to put those APIs to a test.
Modifying the size and color of the icon
Let’s first see whether we can adjust our icon according to our needs without disturbing the button. After all, that was the major promise of those patterns, isn’t it?
First: icon as React Element
Couldn’t have been easier: all we need is just pass some props to the icon. We are using material UI icons, they give us fontSize
and color
for that.
<ButtonWithIconElement icon={<AccessAlarmIconGoogle fontSize="small" color="warning" />}>button here</ButtonWithIconElement>
Second: icon as a Component
Also simple: we need to extract our icon into a component, and pass the props there in the return element.
const AccessAlarmIcon = () => <AccessAlarmIconGoogle fontSize="small" color="error" />;
const Page = () => {
return <ButtonWithIconComponent Icon={AccessAlarmIcon}>button here</ButtonWithIconComponent>;
};
Important: the AccessAlarmIcon
component should always be defined outside of the Page
component, otherwise it will re-create this component on every Page
re-render, and that is really bad for performance and prone to bugs. If you're not familiar with how quickly it can turn ugly, this is the article for you: How to write performant React code: rules, patterns, do's and don'ts
Third: icon as a Function
Almost the same as the first one: just pass the props to the element.
<ButtonWithIconRenderFunc
renderIcon={() => (
<AccessAlarmIconGoogle fontSize="small" color="success" />
)}
>
Easily done for all three of them, we have infinite flexibility to modify the Icon
and didn't need to touch the button for a single thing. Compare it with iconName
and iconProps
from the very first example 🙂
Default values for the icon size in the button
You might have noticed, that I used the same icon size for all three examples. And when implementing a generic button component, more likely than not, you’ll have some prop that control button’s size as well. Infinity flexibility is good, but for something as design systems, you’d want some pre-defined types of buttons. And for different buttons sizes, you’d want the button to control the size of the icon, not leave it to the consumer, so you won’t end up with tiny icons in huge buttons or vice versa by accident.
Now it’s getting interesting: is it possible for the button to control one aspect of an icon while leaving the flexibility intact?
First: icon as React Element
For this one, it gets a little bit ugly. We receive our icon as a pre-defined element already, so the only thing we can do is to clone that element by using React.cloneElement
api and override some of its props:
// in the button component
const clonedIcon = React.cloneElement(icon, { fontSize: 'small' });
return (
<button>
{clonedIcon}
{children}
</button>
);
And at the consumer side we can just remove the fontSize
property.
<ButtonWithIconElement icon={<AccessAlarmIconGoogle color="warning" />} />
But what about default value, not overriding? What if I want consumers to be able to change the size of the icon if they need to?
Still possible, although even uglier, just nee to extract the passed props from the element and put them as default value:
const clonedIcon = React.cloneElement(icon, {
fontSize: icon.props.fontSize || 'small',
});
From the consumer side everything stays as it was before
<ButtonWithIconElement icon={<AccessAlarmIconGoogle color="warning" fontSize="large" />} />
Second: icon as a Component
Even more interesting here. First, we need to give the icon the default value on button side:
export const ButtonWithIconComponent = ({ children, Icon }: ButtonProps) => {
return (
<button>
<Icon fontSize="small" />
{children}
</button>
);
};
And this is going to work perfectly when we pass the directly imported icon:
import AccessAlarmIconGoogle from '@mui/icons-material/AccessAlarm';
<ButtonWithIconComponent Icon={AccessAlarmIconGoogle}>button here</ButtonWithIconComponent>;
Icon
prop is nothing more than just a reference to material UI icon component here, and that one knows how to deal with those props. But we extracted this icon to a component when we had to pass to it some color, remember?
const AccessAlarmIcon = () => <AccessAlarmIconGoogle fontSize="small" color="error" />;
Now the props’ Icon
is a reference to that wrapper component, and it just assumes that it doesn't have any props. So our fontSize
value from <Icon fontSize="small" />
from the button will be just swallowed. This whole pattern, if you've never worked with it before, can be confusing, since it creates this a bit weird mental circle that you need to navigate in order to understand what goes where.
In order to fix the icon, we just need to pass through the props that AccessAlarmIcon
receives to the actual icon. Usually, it's done via spread:
const AccessAlarmIcon = (props) => <AccessAlarmIconGoogle {...props} color="error" />;
Or can be just hand-picked as well:
const AccessAlarmIcon = (props) => <AccessAlarmIconGoogle fontSize={props.fontSize} color="error" />;
While this pattern seems complicated, it actually gives us perfect flexibility: the button can easily set its own props, and the consumer can choose whether they want to follow the direction buttons gives and how much of it they want, or whether they want to do their own thing. If, for example, I want to override button’s value and set my own icon size, all I need to do is to ignore the prop that comes from the button:
const AccessAlarmIcon = (props) => (
// just ignore all the props coming from the button here
// and override with our own values
<AccessAlarmIconGoogle fontSize="large" color="error" />
);
Third: icon as a Function
This is going to be pretty much the same as with icon as a Component, only with the function. First, adjust the button to pass settings to the renderIcon
function:
const icon = renderIcon({
fontSize: 'small',
});
And then on the consumer side, similar to props in Component step, pass that setting to the rendered component:
<ButtonWithIconRenderFunc renderIcon={(settings) => <AccessAlarmIconGoogle fontSize={settings.fontSize} color="success" />}>
button here
</ButtonWithIconRenderFunc>
And again, if we want to override the size, all we need to do is to ignore the setting and pass our own value:
<ButtonWithIconRenderFunc
// ignore the setting here and write our own fontSize
renderIcon={(settings) => <AccessAlarmIconGoogle fontSize="large" color="success" />}
>
button here
</ButtonWithIconRenderFunc>
See the codesandbox with all three examples.
Changing the icon when the button is hovered
And now the final test that should decide everything: I want to give the ability for the users to modify the icon when the button is hovered.
First, let’s teach the button to notice the hover. Just some state and callbacks to set that state should do it:
export const ButtonWithIcon = (...) => {
const [isHovered, setIsHovered] = useState(false);
return (
<button
onMouseOver={() => setIsHovered(true)}
onMouseOut={() => setIsHovered(false)}
>
...
</button>
);
};
And then the icons.
First: icon as React Element
That one is the most interesting of the bunch. First, we need to pass that isHover
prop to the icon from the button:
const clonedIcon = React.cloneElement(icon, {
fontSize: icon.props.fontSize || 'small',
isHovered: isHovered,
});
And now, interestingly enough, we created exactly the same mental circle that we had when we implemented “icon as Component”. We passed isHover
property to the icon component, now we need to go to the consumer, wrap that original icon component into another component, that component will have isHover
prop from the button, and it should return the icon we want to render in the button. 🤯 If you managed to understand that explanation from just words I'll send you some chocolate 😅 Here's some code to make it easier.
Instead of the original simple direct render of the icon:
<ButtonWithIconElement icon={<AccessAlarmIconGoogle color="warning" />}>button here</ButtonWithIconElement>
we should create a wrapper component that has isHovered
in its props and renders that icons as a result:
const AlarmIconWithHoverForElement = (props) => {
return (
<AccessAlarmIconGoogle
// don't forget to spread all the props!
// otherwise you'll lose all the defaults the button is setting
{...props}
// and just override the color based on the value of `isHover`
color={props.isHovered ? 'primary' : 'warning'}
/>
);
};
And then render that new component in the button itself:
<ButtonWithIconElement icon={<AlarmIconWithHoverForElement />}>button here</ButtonWithIconElement>
Looks a little bit weird, but it works perfectly 🤷🏽♀️
Second: icon as a Component
First, pass the isHover
to the icon in the button:
<Icon fontSize="small" isHovered={isHovered} />
And then back to the consumer. And now the funniest thing ever. In the previous step we created exactly the same mental circle that we need to remember when we’re dealing with components passed as Components. And it’s not just the mental picture of data flow, I can literally re-use exactly the same component from the previous step here! They are just components with some props after all:
<ButtonWithIconComponent Icon={AlarmIconWithHoverForElement}>button here</ButtonWithIconComponent>
💥 works perfectly.
Third: icon as a Function
Same story: just pass the isHovered
value to the function as the arguments:
const icon = renderIcon({
fontSize: 'small',
isHovered: isHovered,
});
And then use it on the consumer side:
<ButtonWithIconRenderFunc
renderIcon={(settings) => (
<AccessAlarmIconGoogle
fontSize={settings.fontSize}
color={settings.isHovered ? "primary" : "warning"}
/>
)}
>
🎉 again, works perfectly.
Take a look at the sandbox with the working solution.
Summary and the answer: which way is The Right Way™️?
If you read the full article, you’re probably saying right now: Nadia, aren’t they are basically the same thing? What’s the difference? You promised a clear answer, but I don’t see it ☹️ And you’re right.
And if you just scrolled here right away because you love spoilers: I’m sorry, I lied a bit for the sake of the story 😳. There is no right answer here.
All of them are more or less the same and you probably can implement 99% of the needed use cases (if not 100%) with just one pattern everywhere. The only difference here is semantics, which area has the most complexity, and personal preferences and religious beliefs.
If I had to extract some general rules of which pattern should be used where, I’d probably go with something like this:
- I’d use “component as an Element” pattern (
<Button icon={<Icon />} />
) for cases, where I just need to render the component in a pre-defined place, without modifying its props in the "receiving" component. - I’d use “component as a Component” pattern (
<Button Icon={Icon} />
) when I need to heavily modify and customise this component on the "receiving" side through its props, while at the same time allowing users full flexibility to override those props themselves (pretty much as react-select does forcomponents
prop). - I’d use “component as a Function” pattern (
<Button renderIcon={() => <Icon />} />
) when I need the consumer to modify the result of this function, depending on some values coming from the "host" component itself (pretty much what Material UI Data Grid component does withrenderCell
prop)
Hope this article made those patterns easier to understand and now you can use all of them when the use case needs it. Or you can now just totally ban any of them in your repo, just for fun or consistency sake, since now you can implement whatever you want with just one pattern 😊
See ya next time! ✌🏼
Originally published at https://www.developerway.com. The website has more articles like this 😉
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.