A guide to building flexible compound React components
Iva Kop
· 10 min read
Building React components is hard. There are so many different ways to solve the same problem. How can we, as developers, make informed decisions about which approach to choose? More fundamentally, what is the mechanism through which we can ensure we are making the right choice?
Let me give you an example!
The component
Let's go through the steps of creating a component where a user can choose a subscription plan. It might look something like this:
This is a simplified version of a component we built recently for one of our projects. I stripped away the styles so we can focus on the code.
So where do we start?
The approach
When building a React component, it is important to have a clear idea of the ultimate goal. In this case, let's focus on the following priorities:
-
Usability - make the usage of the component (by developers) as convenient as possible
-
Flexibility - make the component as flexible as possible (without adding unnecessary complexity)
For our concrete example, we want to create our Subscriptions
component in such a way that, ideally, it handles its state internally - both the selected plan and the selected billing period. It also has a callback though which the information is accessible from the outside.
Let's give it a try!
The easy way out
At first, we might be tempted to do something like this:
const SubscriptionsPage = ({ subscriptionPlans }) => (
<Subscriptions
subscriptionPlans={subscriptionPlans}
onSelect={(selectedPlan) => {
// Do something with this data
}}
/>
);
Wow, this looks really easy to use! ✅
But what about flexibility? Imagine that in the future we need to pass a special prop to one of the subscription plans inside? We will have to go through the subscriptionPlans data (likely coming from an API) to add the new prop. Sounds inconvenient...
Or what if I need to insert a component in between those subscription plans to add a message, for example? Or maybe I need to wrap them in an additional component for some reason? I won't be able to do it easily.
Let's think of a better way!
Array of components
What if we try this instead?
const SubscriptionsPage = ({ subscriptionPlans }) => (
<Subscriptions
subscriptionPlans={[
<SubscriptionPlan {...subscriptionPlanProps} />,
<SubscriptionPlan {...subscriptionPlanProps} />,
<SubscriptionPlan {...subscriptionPlanProps} />,
]}
onSelect={(selectedPlan) => {
// Do something with this data
}}
/>
);
Hm... 🤔 This approach certainly solves some of the flexibility issues. It is now easy to add props to the SubscriptionPlan
components. But it is still difficult to insert other components between or around those plans. Also, passing the subscriptionPlans
prop in this way, though sometimes useful, makes the component more difficult to use in this particular case. So even though we improved the flexibility, it came at a price.
Let's try again!
Composition
How about this?
const SubscriptionsPage = () => (
<Subscriptions
onSelect={(selectedPlan) => {
// Do something with this data
}}
>
<SubscriptionPlan {...subscriptionPlanProps} />
<SubscriptionPlan {...subscriptionPlanProps} />
<SubscriptionPlan {...subscriptionPlanProps} />
</Subscriptions>
);
Wow! 😲 This one is so intuitive to use - the SubscriptionPlan
components are simply nested inside the Subscriptions
. It is now easy to pass additional props to SubscriptionPlan
. It is also trivial to add more components between or around any SubscriptionPlan
component.
We have a winner! ✨
The implementation
Now that we have a clear idea of what we want to build, it is time to get our hands dirty.
Let's start by creating the radio inputs. As we mentioned above, the state will be controlled by the Subscriptions
component, so our RadioGroup
component might look something like this:
import React from 'react';
const RadioGroup = ({ value, onChange }) => (
<div>
<input
type="radio"
id="monthly"
name="billingPeriod"
value="MONTH"
checked={value === 'MONTH'}
onChange={(e) => onChange(e.target.value)}
/>
<label htmlFor="monthly">Monthly</label>
<input
type="radio"
id="annual"
name="billingPeriod"
value="ANNUAL"
checked={value === 'ANNUAL'}
onChange={(e) => onChange(e.target.value)}
/>
<label htmlFor="annual">Annual</label>
</div>
);
export default RadioGroup;
Nice start!
How about we add it to the Subscriptions
component next? Let's also create the Select button with a callback, just to make sure everything works.
import React, { useState } from 'react';
import RadioGroup from './RadioGroup';
const Subscriptions = ({ onSelect, children }) => {
const [billingPeriod, setBillingPeriod] = useState('MONTH');
return (
<div>
<RadioGroup
value={billingPeriod}
onChange={(value) => {
setBillingPeriod(value);
}}
/>
{children}
<button
disabled={!selectedPlanId}
onClick={() => onSelect({ billingPeriod })}
>
Select
</button>
</div>
);
};
export default Subscriptions;
Good! It looks like all we are missing is the SubscriptionPlan
and we are ready to go.
Let's create it!
import React from 'react';
const SubscriptionPlan = ({
id,
selectedPlanId,
annualPrice,
monthlyPrice,
name,
onSelectPlan,
billingPeriod,
}) => {
const isSelected = selectedPlanId === id;
const price = billingPeriod === 'MONTH' ? monthlyPrice : annualPrice;
return (
<div onClick={() => onSelectPlan(id)}>
<div>
<h2>{name}</h2>
<h2>$ {price}</h2>
</div>
</div>
);
};
export default SubscriptionPlan;
But wait...
SubscriptionPlan
has to be aware of both the selected billingPeriod
(in order to know which sum to display) and of the selectedPlanId
(in order to know if it should apply the selected styles to itself or not). What is more, it should have an onClick
callback which changes the selected plan state of the Subscriptions
component. How do we handle this?
Let's look at our options.
Lift the state up
One way that we can approach this is to give up on the idea that the Subscriptions
component should handle its own state. Instead, we can lift the state up one level and let the parent component manage it.
This is not a good option as it goes against our "easy to use" principle. We are forcing the developer who is using our component to do a lot of work.
Let's think of something else.
Clone the children
In React, we can copy components and add additional props to them by cloning them. This solution might work here. Let's see what it would look like in our Subscriptions
component.
import React, { useState, Children, cloneElement } from 'react';
import RadioGroup from './RadioGroup';
const Subscriptions = ({ onSelect, children }) => {
const [selectedPlanId, setSelectedPlanId] = useState();
const [billingPeriod, setBillingPeriod] = useState('MONTH');
return (
<div>
<RadioGroup
value={billingPeriod}
onChange={(value) => {
setBillingPeriod(value);
}}
/>
{Children.map(children, (child) =>
child.type === SubscriptionPlan
? cloneElement(child, {
selectedPlanId: selectedPlanId,
billingPeriod,
onSelectPlan: (id) => {
setSelectedPlanId(id);
},
})
: child
)}
<button
disabled={!selectedPlanId}
onClick={() => onSelect({ id: selectedPlanId, billingPeriod })}
>
Select
</button>
</div>
);
};
export default Subscriptions;
Not too bad! But let's think for a second. We only want to pass the additional props to SubscriptionPlan
, never to other children that the component might have. We already check for this in the code above. But are we absolutely certain that SubscriptionPlan
will always be a direct child of Subscriptions
?
What happens if there is another component wrapped around SubscriptionPlan
? To ensure our code works in that situation, we would have to recursively clone all children, check if they are of type SubscriptionPlan
and if so, apply the additional props to them. It is certainly possible to do this but it would add so much complexity. It seems we hit a limitation with this approach.
Is there an alternative?
Render prop
React's render prop pattern is another way to approach this. It will require a small change in the way we use the component though. Instead of adding the children directly, we need to pass a function:
const SubscriptionsPage = () => (
<Subscriptions
onSelect={(selectedPlan) => {
// Do something with this data
}}
>
{(addedProps) => (
<>
<SubscriptionPlan {...subscriptionPlans} {...addedProps} />
<SubscriptionPlan {...subscriptionPlans} {...addedProps} />
<SubscriptionPlan {...subscriptionPlans} {...addedProps} />
</>
)}
</Subscriptions>
);
We now have to manually pass the extra props to SubscriptionPlan
every time we use Subscriptions
. Not great.
Let's also take a look at the Subscriptions
component :
import React, { useState } from 'react';
import RadioGroup from './RadioGroup';
const Subscriptions = ({ onSelect, children }) => {
const [selectedPlanId, setSelectedPlanId] = useState();
const [billingPeriod, setBillingPeriod] = useState('MONTH');
return (
<div>
<RadioGroup
value={billingPeriod}
onChange={(value) => {
setBillingPeriod(value);
}}
/>
{children({
selectedPlanId: selectedPlanId,
billingPeriod,
onSelectPlan: (id) => {
setSelectedPlanId(id);
},
})}
<button
disabled={!selectedPlanId}
onClick={() => onSelect({ id: selectedPlanId, billingPeriod })}
>
Select
</button>
</div>
);
};
export default Subscriptions;
The render prop pattern is useful. We managed to avoid the limitations of the cloning solution. But I still don't like that we are forces to manually pass props.
Is there a way around this?
Context
Let's leverage React's Context API. We can create a context that is accessible for all children of Subscriptions
. Then, we can access this context in SubscriptionPlan
and voilà!
Subscriptions
now looks like this:
import React, { useState, createContext, useContext } from 'react';
import SubscriptionPlan from './SubscriptionPlan';
import RadioGroup from './RadioGroup';
const SubscriptionsContext = createContext({});
export const useSubscritionsContext = () => useContext(SubscriptionsContext);
const Subscriptions = ({ onSelect, children }) => {
const [selectedPlanId, setSelectedPlanId] = useState();
const [billingPeriod, setBillingPeriod] = useState('MONTH');
return (
<div>
<RadioGroup
value={billingPeriod}
onChange={(value) => {
setBillingPeriod(value);
}}
/>
<SubscriptionsContext.Provider
value={{
selectedPlanId: selectedPlanId,
billingPeriod,
onSelectPlan: (id) => {
setSelectedPlanId(id);
},
}}
>
{children}
</SubscriptionsContext.Provider>
<button
disabled={!selectedPlanId}
onClick={() => onSelect({ id: selectedPlanId, billingPeriod })}
>
Select
</button>
</div>
);
};
export { useSubscritionsContext };
export default Subscriptions;
We also need to edit SubscriptionPlan
so that it uses the context we just created:
import React from 'react';
import { useSubscritionsContext } from '../';
const SubscriptionPlan = ({ id, annualPrice, monthlyPrice, name }) => {
const { selectedPlanId, onSelectPlan, billingPeriod } =
useSubscritionsContext();
const isSelected = selectedPlanId === id;
const price = billingPeriod === 'MONTH' ? monthlyPrice : annualPrice;
return (
<div onClick={() => onSelectPlan(id)}>
<div>
<h2>{name}</h2>
<h2>$ {price}</h2>
</div>
</div>
);
};
export default SubscriptionPlan;
Using context slightly complicates the implementation of both of Subscriptions
and SubscriptionPlan
components. But note, we are impacted by this complication once - when we create these components. On the flip side, we reap the benefits of a simple to use component every time we reuse Subscriptions
.
Finally, we now have the component we set out to create in the beginning! 🍾
Check out this repo if you want to play around with the code.
Conclusion
Phew, that was a lot! 😅
So what is the takeaway? Should we just use context for everything?
Absolutely not! All of the approaches and patterns we discussed above and decided against for our particular use case are extremely useful in other contexts (no pun intended). The list above is also far from exhaustive, it includes only what I subjectively considered to be the most relevant solutions.
What I am really trying to convey is a mental model, an algorithm, for approaching the development of React components. What it comes down to is being aware of the different options to solve a problem, understanding the trade-offs between them and, ultimately, going with the solution that fits your goal the best.
In this case, the goal was maximum usability and flexibility. Depending on the project, the team, the stack, the speed of development, and many, many other factors, there might be different goals. As a consequence, the solutions we choose under different circumstances might also differ, even if the component itself remains unchanged.
Happy coding! ✨
Join my newsletter
Subscribe to get my latest content by email.
I will never send you spam. You can unsubscribe at any time.