DEV Community

Nadia Makarevich
Nadia Makarevich

Posted on • Updated on • Originally published at developerway.com

React component as prop: the right way™️

Image description

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

And then can use it like this:

<ButtonWithIconElement icon={<AccessAlarmIconGoogle />}>button here</ButtonWithIconElement>
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

And then can use it like this:

import AccessAlarmIconGoogle from '@mui/icons-material/AccessAlarm';

<ButtonWithIconComponent Icon={AccessAlarmIconGoogle}>button here</ButtonWithIconComponent>;
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

And then use it like this:

<ButtonWithIconRenderFunc renderIcon={() => <AccessAlarmIconGoogle />}>button here</ButtonWithIconRenderFunc>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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" />
  )}
>
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

And at the consumer side we can just remove the fontSize property.

<ButtonWithIconElement icon={<AccessAlarmIconGoogle color="warning" />} />
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

From the consumer side everything stays as it was before

<ButtonWithIconElement icon={<AccessAlarmIconGoogle color="warning" fontSize="large" />} />
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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" />;
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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" />;
Enter fullscreen mode Exit fullscreen mode

Or can be just hand-picked as well:

const AccessAlarmIcon = (props) => <AccessAlarmIconGoogle fontSize={props.fontSize} color="error" />;
Enter fullscreen mode Exit fullscreen mode

Image description

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" />
);
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

And then render that new component in the button itself:

<ButtonWithIconElement icon={<AlarmIconWithHoverForElement />}>button here</ButtonWithIconElement>
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

💥 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,
});
Enter fullscreen mode Exit fullscreen mode

And then use it on the consumer side:

<ButtonWithIconRenderFunc
  renderIcon={(settings) => (
    <AccessAlarmIconGoogle
      fontSize={settings.fontSize}
      color={settings.isHovered ? "primary" : "warning"}
    />
  )}
>
Enter fullscreen mode Exit fullscreen mode

🎉 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 for components 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 with renderCell 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.

Top comments (6)

Collapse
 
kwirke profile image
Kwirke

But there is a big difference between element and component/function: Who renders the component.
Elements are components being rendered by the parent, while components/functions are components that will be rendered by the child.
With complex lifecycles and state dependencies, this can make a huge impact.
You can look at it this way: Functions/components are "lazy elements" (elements that will be rendered when needed). This can also affect performance.

Collapse
 
adevnadia profile image
Nadia Makarevich

Not exactly. Even with element approach, the component is still rendered by the child. Parent will generate only this component's description, it will not trigger any of the lifecycle events, including render.

Check this out: codesandbox.io/s/buttons-with-icon...

I removed the icon from button's render, while still passing it from parent - and it's render is not triggered.

So the effect of that on parent will be comparable with creating an object there, and will be very cheap. Otherwise latest react-router wouldn't have been able to transition to this model: reactrouter.com/docs/en/v6/upgradi...

Collapse
 
kwirke profile image
Kwirke

Huh, interesting. Thank you very much for correcting me!
I'm pretty sure it worked like that in the past (but I could be wrong again). If that's the case, React has advanced quite a lot.

Collapse
 
kutnerjs profile image
Kutner JS

super interesting, I hadn't thought of the third option. It's usual to pass a style or a class name to a "slot" in the component, and using this (args) => ReactNode method could work.
I don't know though, it feels clunky. How long will it be until some developer try to put a hook in it?

Collapse
 
medalimhdh profile image
Mahdhaoui Mohamed Ali

I feel I'v been manipulated :') :')
Just kidding! Thank you that was very instructive!

Collapse
 
snowyang profile image
Snow Yáng

I think you have a typo at here:

Still possible, although even uglier, just** nee** to extract the passed props from the element and put them as default value:

I think it should be need