A guide to building flexible compound React components

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:

  1. Usability - make the usage of the component (by developers) as convenient as possible

  2. 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.

MORE ARTICLES

© 2022 Iva Kop. All rights reserved

Privacy policyRSS