Chapter 3.
Configuration
concerns with elements as props
In the previous chapter, we explored how passing elements as props can
improve the performance of our apps. However, performance
enhancements are not the most common use of this pattern. In fact, they
are more of a nice side effect and relatively unknown. The biggest use
case this pattern solves is actually flexibility and configuration of
components.
Let's continue our investigation into how React works. This time, we're
going to build a simple "button with icon" component. What could
possibly be complicated about this one, right? But in the process of
building it, you'll find out:
How elements as props can drastically improve configuration
concerns for such components.
How conditional rendering of components influences
performance.
When a component passed as props is rendered exactly.
How to set default props for components passed as props by using
the cloneElement function, and what the downsides of that
are.
Ready? Let's go!
The problem
Imagine, for example, that you need to implement a Button
component. One of the requirements is that the button should be able to
Page 41
show the "loading" icon on the right when it's used in the "loading"
context. Quite a common pattern for data sending in forms.
No problem! We can just implement the button and add the
isLoading prop, based on which we'll render the icon.
const Button = ({ isLoading }) => {
return (
<button>Submit {isLoading ? <Loading /> : null}</button>
);
};
The next day, this button needs to support all available icons from your
library, not only the Loading . Okay, we can add the iconName prop
to the Button for that. The next day - people want to be able to control
the color of that icon so that it aligns better with the color palette used
on the website. The iconColor prop is added. Then iconSize , to
control the size of the icon. And then, a use case appears for the button
to support icons on the left side as well. And avatars.
Eventually, half of the props on the Button are there just to control
those icons, no one is able to understand what is happening inside, and
every change results in some broken functionality for the customers.
const Button = ({
isLoading,
iconLeftName,
iconLeftColor,
iconLeftSize,
isIconLeftAvatar,
...
}) => {
// no one knows what's happening here and how all those props
work
return ...
}
Page 42
Sounds familiar?
Elements as props
Luckily, there is an easy way to drastically improve this situation. All we
need to do is to get rid of those configuration props and pass the icon as
an Element instead:
const Button = ({ icon }) => {
return <button>Submit {icon}</button>;
};
And then leave it to the consumer to configure that icon in whatever way
they want:
// default Loading icon
<Button icon={<Loading />} />
// red Error icon
<Button icon={<Error color="red" />} />
// yellow large Warning icon
<Button icon={<Warning color="yellow" size="large" />} />
// avatar instead of icon
<Button icon={<Avatar />} />
Interactive example and full code
[Link]
Whether doing something like this for a Button is a good idea or not is
sometimes debatable, of course. It highly depends on how strict your
Page 43
design is and how much deviation it allows for those who implement
product features.
But imagine implementing something like a modal dialog with a header,
content area, and footer with some buttons.
Unless your designers are very strict and powerful, chances are you'd
need to have different configurations of those buttons in different
dialogs: one, two, three buttons, one button is a link, one button is
"primary," different texts on those of course, different icons, different
tooltips, etc. Imagine passing all of that through configuration props!
But with elements as props, it couldn't be easier: just create a footer
prop on the dialog
const ModalDialog = ({ content, footer }) => {
return (
<div className="modal-dialog">
<div className="content">{content}</div>
<div className="footer">{footer}</div>
</div>
);
};
and then pass whatever is needed:
// just one button in footer
<ModalDialog content={<SomeFormHere />} footer={<SubmitButton />}
/>
// two buttons
<ModalDialog
content={<SomeFormHere />}
footer={<><SubmitButton /><CancelButton /></>}
/>
Page 44
Interactive example and full code
[Link]
Or something like a ThreeColumnsLayout component, which
renders three columns with some content on the screen. In this case, you
can't even do any configuration: it literally can and should render
anything in those columns.
<ThreeColumnsLayout
leftColumn={<Something />}
middleColumn={<OtherThing />}
rightColumn={<SomethingElse />}
/>
Interactive example and full code
[Link]
Essentially, an element as a prop for a component is a way to tell the
consumer: give me whatever you want, I don't know or care what it is, I
am just responsible for putting it in the right place. The rest is up to you.
And, of course, the "children" as props pattern, described in the previous
chapter, is very useful here as well. If we want to pass something that we
consider a "main" part of that component, like the "content" area in the
modal dialog, or the middle column in the three columns layout, we can
just use the nested syntax for that:
// before
<ModalDialog
content={<SomeFormHere />}
footer={<SubmitButton />}
/>
Page 45
// after
<ModalDialog
footer={<SubmitButton />}
>
<SomeFormHere />
</ModalDialog>
All we need to do, from the ModalDialog perspective, is to rename the
"content" prop to "children":
const ModalDialog = ({ children, footer }) => {
return (
<div className="dialog">
<div className="content">{children}</div>
<div className="footer">{footer}</div>
</div>
);
};
Interactive example and full code
[Link]
Always remember: "children" in this context are nothing more than a
prop, and the "nested" syntax is just syntax sugar for it!
Conditional rendering and
performance
One of the biggest concerns that sometimes arises with this pattern is the
performance of it. Which is ironic, considering that in the previous
chapter, we discussed how to use it to improve performance. So, what's
going on?
Page 46
Imagine we render the component that accepts elements as props
conditionally. Like our ModalDialog , that typically would be rendered
only when the isDialogOpen variable is true:
const App = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
// when is this one going to be rendered?
const footer = <Footer />;
return isDialogOpen ? (
<ModalDialog footer={footer} />
) : null;
};
The question here, with which even very experienced developers
sometimes struggle, is this: we declare our Footer before the dialog.
While the dialog is still closed and won't be open for a while (or maybe
never). Does this mean that the footer will always be rendered, even if
the dialog is not on the screen? What about the performance
implications? Won't this slow down the App component?
Fortunately, we have nothing to worry about here. Remember, how in
Chapter 2. Elements, children as props, and re-renders we discussed
what an Element is? All we did when we declared the footer variable
( footer = <Footer /> ) was create an Element, nothing more.
From the React and code perspective, it's just an object that sits in
memory quietly and does nothing. And creating objects is cheap (at least
compared to rendering components).
This Footer will actually be rendered only when it ends up in the
return object of one of the components, not sooner. In our case, it will be
the ModalDialog component. It doesn't matter that the <Footer
/> element was created in the App . It's the ModalDialog that will
take it and actually return it:
Page 47
const ModalDialog = ({ children, footer }) => {
return (
<div className="dialog">
<div className="content">{children}</div>
{/* Whatever is coming from footer prop is going to be
rendered only when this entire component renders */}
{/* not sooner */}
<div className="footer">{footer}</div>
</div>
);
};
This is what makes routing patterns, like in one of the versions of React
router, completely safe:
const App = () => {
return (
<>
<Route path="/some/path" element={<Page />} />
<Route path="/other/path" element={<OtherPage />} />
...
</>
);
};
There is no condition here, so it feels like the App owns and renders
both <Page /> and <OtherPage /> at the same time. But it
doesn't. It just creates small objects that describe those pages. The actual
rendering will only happen when the path in one of the routes matches
the URL and the element prop is actually returned from the Route
component.
Default values for the elements from
props
Page 48
Let's talk about our button and its icons a little bit more.
One of the objections against passing those icons as props is that this
pattern is too flexible. It's okay for the ThreeColumnsLayout
component to accept anything in the leftColumn prop. But in the
Button's case, we don't really want to pass everything there. In the real
world, the Button would need to have some degree of control over the
icons. If the button has the isDisabled property, you'd likely want
the icon to appear "disabled" as well. Bigger buttons would want bigger
icons by default. Blue buttons would want white icons by default, and
white buttons would want black icons.
However, if we leave the implementation as it is now, this will be
problematic: it will be up to the Button 's consumers to remember all
that.
// primary button should have white icons
<Button appearance="primary" icon={<Loading color="white" />} />
// secondary button should have black icons
<Button appearance="secondary" icon={<Loading color="black" />} />
// large button should have large icons
<Button size="large" icon={<Loading size="large" />} />
Half of the time, it will be forgotten, and the other half misunderstood.
What we need here is to assign some default values to those icons that
the Button can control while still preserving the flexibility of the
pattern.
Luckily, we can do exactly that. Remember that these icons in props are
just objects with known and predictable shapes. And React has APIs that
allow us to operate on them easily. In our case, we can clone the icon in
the Button with the help of the [Link] function[3],
and assign any props to that new element that we want. So nothing stops
us from creating some default icon props, merging them together with
Page 49
the props coming from the original icon, and assigning them to the
cloned icon:
const Button = ({ appearance, size, icon }) => {
// create default props
const defaultIconProps = {
size: size === 'large' ? 'large' : 'medium',
color: appearance === 'primary' ? 'white' : 'black',
};
const newProps = {
...defaultIconProps,
// make sure that props that are coming from the icon override
default if they exist
...[Link],
};
// clone the icon and assign new props to it
const clonedIcon = [Link](icon, newProps);
return <button>Submit {clonedIcon}</button>;
};
And now, all of our Button with icon examples will be reduced to just
this:
// primary button will have white icons
<Button appearance="primary" icon={<Loading />} />
// secondary button will have black icons
<Button appearance="secondary" icon={<Loading />} />
// large button will have large icons
<Button size="large" icon={<Loading />} />
Interactive example and full code
[Link]
Page 50
No additional props on any of the icons, just the default props that are
controlled by the button now! And then, if someone really needs to
override the default value, they can still do it: by passing the prop as
usual.
// override the default black color with red icons
<Button
appearance="secondary"
icon={<Loading color="red" />}
/>
In fact, consumers of the Button won't even know about the default
props. For them, the icon will just work like magic.
Why we shouldn't go crazy with default values
Speaking of magic: the fact that setting default values works seemingly
magically can be a big downside. The biggest problem here is that it's
way too easy to make a mistake and override the props for good. If, for
example, I don't override the default props with the actual props and just
assign them right away:
const Button = ({ appearance, size, icon }) => {
// create default props
const defaultIconProps = {
size: size === 'large' ? 'large' : 'medium',
color: appearance === 'primary' ? 'white' : 'black',
};
// clone the icon and assign the default props to it - don't do
that!
// it will override all the props that are passed to the icon
from the outside!
const clonedIcon = [Link](
icon,
defaultIconProps,
Page 51
);
return <button>Submit {clonedIcon}</button>;
};
I will basically destroy the icon's API. People will try to pass different
sizes or colors to it, but it will never reach the target:
// color "red" won't work here - I never passed those props to the
cloned icon
<Button appearance="secondary" icon={<Loading color="red" />} />
// but if I just render this icon outside the button, it will work
<Loading color="red" />
Interactive example and full code
[Link]
Good luck to anyone trying to understand why setting the color of the
icon outside of the button works perfectly, but doesn't work if the icon is
passed as this prop.
So be very careful with this pattern, and make sure you always override
the default props with the actual props. And if you feel uneasy about it -
no worries. In React, there are a million ways to achieve exactly the same
result. There is another pattern that can be very helpful for this case:
render props. It can also be very helpful if you need to calculate the
icon's props based on the button's state or just plainly pass that state
back to the icon. The next chapter is all about this pattern.
Key takeaways
Before we move on to the Render Props pattern, let's remember:
Page 52
When a component renders another component, the configuration
of which is controlled by props, we can pass the entire component
element as a prop instead, leaving the configuration concerns to
the consumer.
const Button = ({ icon }) => {
return <button>Submit {icon}</button>;
};
// large red Error icon
<Button icon={<Error color="red" size="large" />} />;
If a component that has elements as props is rendered
conditionally, then even if those elements are created outside of
the condition, they will only be rendered when the conditional
component is rendered.
const App = () => {
// footer will be rendered only when the dialog itself renders
// after isDialogOpen is set to "true"
const footer = <Footer />;
return isDialogOpen ? (
<ModalDialog footer={footer} />
) : null;
};
If we need to provide default props to the element from props, we
can use the cloneElement function for that.
This pattern, however, is very fragile. It's too easy to make a
mistake there, so use it only for very simple cases.
Page 53