I am helping out in the front-end at work, and my coworker’s pushing for us to use this pattern in React he calls composability. I was unfamiliar with the pattern, and I’ll be honest upfront, I’m not a big fan.
How it basically goes is: for components that contain multiple sub-elements, instead of exporting 1 component that controls its own sub-elements with props, we export a bunch of components within 1 object variable - one being the root element, the others being any sub-elements. Then, we arrange them in the file that’s importing this stuff, as children of the root element. Sorry if that’s a bit confusing - I’ll give an example below.
Say we want a header component that should contain a title and description.
Instead of making a component like:
// a file called e.g. Header.tsx
type HeaderProps = {
titleText: string
descriptionText: string
}
function Header({titleText}: HeaderProps) {
return (
<header className='MyHeader'>
<h1>{titleText}</h1>
<p className='HeaderDescription'>{descriptionText}</p>
</header>
);
}
// Usage in some other file
import { Header } from './Header.tsx'
// ...
<Header titleText="My blog post from hell" descriptionText="I am sorry" />
you’d do something like:
// Header.tsx
type HeaderTitleProps = {
children: ReactNode
} // I'm typing it out explicitly, but basically just inherit that from the React ComponentProps type
function HeaderTitle ({children}: HeaderTitleProps) {
return <h1>{children}</h1>
}
type HeaderDescriptionProps = {
children: ReactNode
} // same deal here, coming from the React ComponentProps type
function HeaderDescription ({children}: HeaderDescriptionProps) {
return <p className="HeaderDescription">{children}</p>
}
type HeaderProps = {
children: ReactNode
}// you get me
function HeaderRoot({children}: HeaderProps) {
return <header className='MyHeader'>
</header>
}
export const Header = {
Root: HeaderRoot,
Title: HeaderTitle
}
// Usage in some other file
import {Header} from './Header.tsx'
//...
<Header.Root>
<Header.Title></Header.Title>
</Header.Root>
I believe I found where this pattern originated, in this article: “The Future of React: Enhancing Components through Composition Pattern” by Ricardo Silva.. I highly recommend checking it out, it’s very well-written and the example is worth looking into for context.
First off, big thanks to Ricardo Silva - I really appreciate them sharing their knowledge and ideas publicly. Lord knows I recognize the effort that takes. Plus, it’s really quite a creative idea.
I have to say though, I don’t like it.
Let me start with what I like about this idea.
-
I like how it emphasizes compartmentalizing components. In some codebases, you can run into behemoths of components that have a million props controlling the nitty-gritty of their subelements 5 layers down the chain, and it is ugly and weird. I am all for exposing multiple components that contain their own logic.
-
I like the emphasis on children. I think folks often forget that React is literally just Javascript formatted in a cute HTML-like way, and that when you write hey, what this really compiles to is a function X() which is called with, as an argument, an object like { children: “hey” } (don’t quote me on the exact structure, I’m too much on a roll to go check out React docs right now). All this to say, I like when children are utilized explicitly. I’m resisting the urge to make dubious jokes about child labor.
-
There’s something intriguing about this exporting multiple components in 1 variable thing. It reminds me of namespacing, and I do kind of find that nice.
NOW. For the stuff I don’t like.
My main beef is that while I think this approach hits the componentization bit pretty well, it misses the mark on its use of children.
But before I get into that, I’ll talk about some relevant work experience.
# A little (big) tangent
I used to work on the “Marketing” sub-team of the Disney+ web team. We supported the web app for marketing landing pages of Disney+ - you know, when you click an ad, it takes you to a page where you can sign up, and there’s obv a bunch of analytics on it. Those pages were created by marketing folks with a (headless) CMS, and they ran A/B tests on them.
Initially, that CMS was fairly traditional and rigid. You had some elements of composability, but basically the UI to Create a Page was basically like:
Title: {string input}
Hero:
background: {image input}
heading: {string input}
Add Sections:
Section
image: {image input}
text: {text input}
text layout: {left|right|center}
In other words, it was quite rigid. As a result, for basically every A/B test campaign, the Marketing team had to ask the dev team to apply some overrides - inject some HTML somewhere via a script, or do some CSS magic etc.
We started talking about making another CMS in-house, and another dev had the great idea to give it infinite composability, based on atomic design. Marketers would now create tiny components called Atoms, and Molecules based on those atoms, and assemble them into organisms, and eventually, pages. The CMS would export, for each page, a JSON with deeply nested structure:
{
type: Page,
children: [
{
type: Section,
children: [
{
type: Heading1,
children: ...
},
...
]
},
{
type: Footer,
....
}
]
}
Basically, something like a JSON representation of HTML - or more accurately, of React components.
This was a great idea. But eventually, the dev in question realized (long after I did, which caused many heated discussions) that, while the flexibility was great and needed, in practice, we still needed a lot of structure, to 1) enforce rules, and 2) make this actually usable by humans.
There’s 2 types of rules I’m talking about here.
First, semantic HTML and accessibility, which I consider to be on the level of a legal obligation for a dev (in some cases, they are).
HTML is a standard. There’s stuff you should and shouldn’t do. Technically, you can put a <button>
inside an <a>
, in practice, you shouldn’t. Technically, you can put any heading in any order on your page - in practice, the content of your page should respect an order.
Then, there’s the rules that define concepts, and make them repeatable.
When we had a Hero section, we could trust there was always a title in there, and that title was always, content-wise, the most important on the page. Therefore, we needed to enforce that that heading existed, and that it was an H1.
Plus, on the usability side: not only is it a huge PITA to make the marketer create a Heading1 component every time they needed to write a title, then stick it in the Hero component, but also, making this an unenforced manual process is very prone for error. We knew exactly what we needed, why force others to do it for us?
So basically, I kept pushing for us to, yes, keep using sub-nodes, as they simplify many things and provide flexibility - but also, build some narrowly defined properties for things that are required and/or ubiquitous.
This developer complained about how I was attempting to corrupt their atomic design, and the whole point was flexibility. My thought:
- that flexibility was meant to improve productivity. If there’s a bunch of extra work and unenforced expectations that lead to errors, it’s less productive, not more.
- flexibility isn’t a total lack of structure. Even if we follow the atomic analogy, in the physical world, you can’t just build any molecule with any atom. Atoms have to bind in a certain way - don’t ask me how, but there’s rules in physics, I heard.
- flexibility doesn’t mean you have to build everything from scratch. When you say “I need water”, I don’t want you to hand me some hydrogen and oxygen and tell me to put them together.
So essentially, my position was: nodes are great, passing down subcomponents is great, but not everything should be a subcomponent. There’s lots of value in accepting certain rigid properties that we transform into subcomponents under the hood. (This is what we ended up doing, to the marketers’ great relief.)
# My arguments against the approach, generally
My arguments against Silva’s approach boil down to something similar.
# 1) We know what we want. Let’s stop pretending we’re down for whatever.
In the “traditional” implementation of my example, what the imported variable is saying is: I’m a header, and I need you to give me text for both a title and a description (also, if you peek in: I’m going to render those as an H1 and a p).
In the second implementation, what the import is saying is: here’s a bunch of parts. Build them just right.
Here’s the rub. The writer is expecting you to use the Header.Title, and the Header.Description, and they are expecting you to put them in the Root wrapper, otherwise they wouldn’t have put them all in this one variable. They may even expect you to respect a certain order.
However, in reality, you can put in anything in the Header.Root (or any other of the exported components). You can omit the subcomponents, you can put them in any order, or not use them at all, and put a footer in there, why the fuck not. (semantic html screams into its pillow.)
With this export structure, you’re saying these components are so independent, and the root is super chill and down for whatever. Then why do we export these components from within the same variable?
Because the root is not down for whatever, and the subcomponents are conceptually attached to it. You know this, I know this, let’s be clear about it.
I don’t believe in unstated and unenforced expectations. Components, be grown-ups, enforce boundaries, and express your needs! Then we can all hug.
# 2) Children aren’t the only way
I suspect folks kind of forget that children
are not the only way to pass nodes as props.
IMO, children should be for things that are really flexible. Agnostic. Like - if I am a Page component, and between the header and footer, you can give me whatever you want.
But if you expect a given set of subcomponents, let’s just define our “root” component with this expectation. We can still accept all those subcomponents as nodes, by defining the prop as the type ReactNode! I don’t think we always should, but we can.
// a file called e.g. Header.tsx
type HeaderProps = {
title: ReactNode
description: ReactNode
}
function Header({titleText}: HeaderProps) {
return (
<header className='MyHeader'>
{title}
{description}
</header>
);
}
// Usage in some other file
import { Header } from './Header.tsx'
// ...
<Header
title={<h1>My blog post from hell</h1>}
description={<p>I am sorry</p>}
/>
# 3) C is for cascading.
I suspect another part of this confusion is, with all the MUI and other React styling libraries, folks can forget that all we’re rendering is some HTML and CSS. Not only is HTML pretty standardized (see rant about semantic HTML), but the argument about passing down props is pretty much irrelevant when it comes to styling. Good old CSS is built to solve just this problem; styling absolutely doesn’t need to live in individual React (sub) component’s props. If a component expects elements within it to be styled in a certain way, you can (in most cases, should) just stick a class on the root element, and then style the sub-elements within that class.
If you follow that approach, then the HTML that you’re passing to your root can be really freaking agnostic. Just say .MyHeader
makes all h1s inside it be 24px. Done.
So again, I’ll state, the whole “let components manage their own props” argument is pretty irrelevant when we’re talking about CSS.
Jeezus, this is getting super long. But I got more in the tank.
# The article
So now for my response to the article. If anyone is actually reading this, you should read the example in the article now bc I’m going to refer to it a lot.
Also this is a bit repetitive, I’ve some copied stuff from above.
Silva writes that these are the key benefits of this pattern:
- Reduced Prop-drilling
- Explicit Control
- Easier Variants
- Clear Structure
I’ll discuss them individually.
- Reduced Prop-Drilling Exporting components individually allows each sub-component to maintain its own props, eliminating the need to pass props down multiple levels.
I can see this, but also, let’s just remember it doesn’t apply for styling. Again, with all the MUI and other React styling libraries, I suspect folks forget that all we’re rendering is some HTML and CSS, and CSS is built to solve exactly this problem. If a component expects elements within it to be styled in a certain way, we can (should) just stick a class on the root element, and then style the sub-elements within that class.
- Explicit Control Each sub-component can be included or excluded explicitly in the parent component. This eliminates the need for internal conditional logic to apply the different styles or behaviours.
This is a bit awkward, but here goes. I suspect that a lot of this article comes from the iconRight, iconLeft, etc. problem that Silva describes. I was wracking my brain to come up with another use case for this particular problem, but I couldn’t: A solution like having the (one) icon prop be optional wouldn’t be any less explicit than his proposal, right?
So, if this is what they’re referring to… I’ve faced this exact problem before - like, I’m 95% sure I had to deal with literally the CTA with the icon on the left or right. Remember that rigid CMS and the need for overrides? We’d often have to manipulate the order of stuff on the page, but without the ability to edit the HTML.
The thing is: HTML order doesn’t have to be visual order. With CSS Flexbox on a container, you can easily reverse its children, or even specify an order for each child. I can give an example in a bit - I’m going to finish writing first, and writing proper examples in code takes effort (again, kudos Ricardo Silva for making that effort!)
Edit: I re-read this example and turns out I had misread. There can actually be 2 icons simultaneously on either side of the text.
But actually I’m even more confused, firstly I can’t recall ever encountering such a strange requirement as the one described, and secondly because with this lack of constraints on the subcomponents, I really don’t see the purpose of re-exporting Icon or Text from the Button component. They’ve literally got no changes on it compared to the imported component. So like, yeah, by all means make them children, but why re-export them? It’s just weird.
- Easier Variants If new design requirements emerge, such as having an icon on the left instead of the right, we can easily rearrange our composed sub-components without the need for additional props or logic.
Same as the above point: there’s no need to rearrange components in HTML or React to display them in a different order.
- Clear Structure The composition approach provides a clear structure of the expected child components and their relationship to the parent, making it easier to understand what a component’s structure is at a glance.
I am really sorry, but I don’t see it that way. In this approach, pretty much nothing about the structure or the relationships is actually defined within the component. In the component’s definition, that actually makes the relationship of the child and parent components less explicit, and makes the component structure less easy to understand. Yes, we’re defining this when we’re calling the component, but that makes it so that the caller is totally responsible for actually creating this structure and these relationships.
# Proposed alternatives
I’ll briefly show the solutions I would have used for Silva’s example.
- For icon positioning
If this really was just about, as I had initially understood, just switching the positioning of an icon. I would have used a single Icon component and some CSS.
interface CtaButtonProps extends React.ComponentProps<'button'> {
variant?: 'primary' | 'secondary';
size?: 'small' | 'large';
// my changes here
icon: React.ReactNode;
iconPosition: 'left'|'right';
text: string;
}
export const CtaButton: React.FC<CtaButtonProps> = ({
variant = 'primary',
size = 'medium',
icon,
iconPosition,
// tbh, we could easily pass this as just a className
// but I'll keep the pattern he's using for variant and size as separate props
className,
text,
...props
}) => {
const classes = `${variant} ${size} icon-${iconAlignment} ${className}`;
return (
<button className={classes} {...props}>
{icon}
<span>{text}</span>
</button>
);
};
// In implementation
<CtaButton
icon={<Icon name="icon" size="30" color="green" />}
iconPosition="right"
text="Add more"
/>
The CSS
btn {
display: flex;
}
btn.icon-left {
flex-direction: row;
}
btn.icon-right {
flex-direction: row-reverse;
}
- two icons that need to sometimes appear and sometimes not
This is where I’d say, indeed, if the contents of the button can have so many permutations, then this button can accept literally anything and we should make usage of children.
But in that case, I genuinely do not understand why we’d need to re-state the icon or text component? At this point the parent is actually agnostic to the styling, etc, of its children.
What was the reason?????? Cardi voice
Here’s the much more simple way to look at this:
interface CtaRootProps extends React.ComponentProps<'button'> {
variant?: 'primary' | 'secondary';
size?: 'small' | 'large';
}
export const CtaButton: React.FC<CtaRootProps> = ({
variant = 'primary',
size = 'medium',
className,
children,
...props
}) => {
const classes = `btn ${variant} ${size} ${className}`;
return (
<button className={classes} {...props}>
{children}
</button>
);
};
// THAT'S IT! STOP THERE!! NO NEED TO REXPORT THE OTHER STUFF! It serves no purpose!
// Then simply do
{/* Icon Left */}
<CtaButton>
<Icon name="+" color="red" size={20} />
<Text>Add more</Cta>
<CtaButton>
{/* Icon Right */}
<CtaButton>
<Text>Add more</Text>
<Icon name="+" color="green" size={20} />
<CtaButton>
{/* Icon Both sides with different colors and sizes */}
<CtaButton>
<Icon name="+" color="red" size={20} />
<Text>Add more</Text>
<Icon name="+" color="green" size={30} />
</CtaButton>
I guess what Ricardo Silva wants to do is somewhat document that the button usually expects one or two icons or text? Because like, at this point the wrapper is totally agnostic, so it’s not even about styling these icons since they can be literally any size or color. So what I’d say is, buddy, just give up! your designers have clearly shown they want to put literally anything in their buttons. It’s icons today, it’ll be headings and banners next week. Stop trying to predict it and accept the chaos. They’ve gone rogue. There’s no standard. Let’s pass children sight-unseen and let’s call it a day.
That being said, because I am a thorough bitch, I am going to offer an alternative, and illustrate there’s other ways to pass nodes than children.
Note that I could have called the props iconLeft and iconRight instead of beforeText and afterText, but I’ve accepted that your designers DGAF, so we’re not going to commit to that. It looks like text is going to be the only predictable thing in that button. Trust and believe that’s not going to be the case for long buddy. They’re going to do CTAs with a gif only in the next one.
interface CtaRootProps extends React.ComponentProps<'button'> {
variant?: 'primary' | 'secondary';
size?: 'small' | 'large';
beforeText?: React.ReactNode;
afterText?: React.ReactNode;
text: string,
}
export const CtaButton: React.FC<CtaRootProps> = ({
variant = 'primary',
size = 'medium',
className,
beforeText,
afterText,
text,
...props
}) => {
const classes = `btn ${variant} ${size} ${className}`;
return (
<button className={classes} {...props}>
{beforeText}
<span>{text}</span>
{afterText}
</button>
);
};
// Usage
{/* Icon Left */}
<CtaButton
beforeText={
<Icon name="+" color="red" size={20} />
}
text="Add more"
/>
{/* Icon Right */}
<CtaButton
afterText={
<Icon name="+" color="green" size={20} />
}
text="Add more"
/>
{/* Icon Both sides with different colors and sizes */}
<CtaButton
beforeText={
<Icon name="+" color="red" size={20} />
}
afterText={
<Icon name="+" color="green" size={20} />
}
text="Add more"
/>
# Conclusion
Again, I want to thank Ricardo Silva for this contribution, and highlight that there are some things I really do like about it. I just think the approach is a vast over-optimization for a problem that can much more simply be solved with CSS standard react children??
If anyone disagrees or has thoughts, please do let me know, I don’t like talking into a void and I can recognize I am very, very occasionally wrong!
Damn, for anyone who was looking forward to me talking about tech stuff, I promise I’m not always like this. But I hope this was still nice to read. If anybody did.