DEV Community

Cover image for Compound components - React
Sune Seifert
Sune Seifert

Posted on • Updated on

Compound components - React

What are compound components?

Compound components are just a set of components that belong to each other and work great together.
They are also super flexible and very expandable.

In this tutorial I will focus on a very simple card component example that hopefully explains itself and how easy the compound component pattern really is.

I will not focus on styling/CSS, so if you are following along and testing the code for yourself, you must add your own CSS (inline styling, SASS/SCSS, external style-sheet, CSS-modules, styled components, etc.). For example in the code examples further down this article, I'm toggling a CSS class (BEM modifier), which indicates that an external style-sheet is being imported with the styling being defined there. What I'm saying is that the code examples below wouldn't work as is, proper styling is needed for the UI to look right.

If you want more information on compound components, you can find a good amount of tutorials/videos out on the great internet, here are some of my favourites that made me start using the compound components pattern:

Kent C. Dodds - React Hooks: Compound Components

  • He uses function components with hooks and explains compound components well, but while he uses a great example for a use case, I think it's a little too hard to understand for beginners, because he uses useCallback and useMemo together with custom hooks and context (I also use context and custom hooks, but not using useCallback and useMemo I believe it is much easier to understand the concept of compound components).

Ryan Florence - Compound Components

  • This guy is funny and also explains compound components well. He uses class components which is just another (old?) way to create components and in my tutorial I focus on function components/hooks, just bear that in mind.

Example - Card component as compound component

  1. The basics
  2. Creating a scope using context

  3. State management

  4. The power of compound components

The basics

Let's start with the example, which in the end is just a div that takes in the children prop:

function Card({children}){
  return (
    <div className="Card">
      {children}
    </div>
  );
}

export default Card;
Enter fullscreen mode Exit fullscreen mode

which is used like this:

<Card>
  // Content goes here
</Card>
Enter fullscreen mode Exit fullscreen mode

At this point this is just a "normal" component, nothing special there.

Let's add a heading, say an h2:

function Card({children}){
  ...
}

function Heading({children}){
  return (
    <h2 className="Card__heading">
      {children}
    </h2>
  );
}

export Heading;
export default Card;
Enter fullscreen mode Exit fullscreen mode

Maybe you have already seen this way of defining components before (multiple components in the same file), or maybe you just know that this is possible. In theory this is actually almost all there is to compound components. It's that easy, because now you can do this:

<Card>
  <Heading>My title</Heading>
</Card>
Enter fullscreen mode Exit fullscreen mode

It is not so obvious that the Heading component "belongs" to the Card component, because you can just use the Heading component outside of Card:

<Heading>My title</Heading>
<Card>
  // Oh no, I want my Heading to only be in here!
</Card>
Enter fullscreen mode Exit fullscreen mode

Let me show you a slightly different way of exporting the components:

function Card({children}){
  ...
}

function Heading({children}){
  ...
}
Card.Heading = Heading;

export default Card;
Enter fullscreen mode Exit fullscreen mode

Notice how I added the Heading component to the Card component as a property so the Heading now is a method of the Card object. This is because every component you make gets added to Reacts virtual DOM, which is just an object (a giant object), so if the Card component is just a property in the virtual DOM object, why not just add whatever you want to this Card property.

To illustrate it a little better, here is how you use it:

<Card>
  <Card.Heading>My title</Card.Heading>
</Card>
Enter fullscreen mode Exit fullscreen mode

I think this makes it more obvious that the Heading "belongs" to the Card component, but remember, it is just a component, so you can still use the Heading component outside the Card component:

<Card.Heading>My title</Card.Heading>
<Card>
  // Oh no, I want my Heading to only be in here!
</Card>
Enter fullscreen mode Exit fullscreen mode

This is the very basics of compound components and you could stop here and say to yourself that you know how to create compound components, but there is so much more to compound components that make them super powerful and useful, especially in larger projects or for very complex components.

I'll go over most of them here:

Creating a scope using context

If we really want our child components to only work inside the Card component (what I call scope), we must do some extra work (obviously). Here we can take advantage of the context API (don't be scared if you don't fully understand the concept of context, just follow along and it should hopefully make sense. You can also read more about the context API if you want).

Let's start by creating the context by importing the createContext hook from React and create a variable called CardContext that uses this hook (you can call the variable whatever you like, but I think CardContext is a good, descriptive name):

import { createContext } from "react";

var CardContext = createContext();

function Card({children}){
  ...
}

function Heading({children}){
  ...
  ...
Enter fullscreen mode Exit fullscreen mode

We also need a provider for the context, but since we don't have any states or values we want to share via context we just use an empty object as the value in the value prop for the provider:

import { createContext } from "react";

var CardContext = createContext();

function Card({children}){
  return (
    <CardContext.Provider value={{}}>
      <div className="Card">
        {children}
      </div>
    </CardContext.Provider>
  );
}

function Heading({children}){
  ...
  ...
Enter fullscreen mode Exit fullscreen mode

The CardContext.Provider is, simply put, a container that holds any value value={// whatever you want} which is then available to all nested children.

To access the values (if we had any) we simply use the useContext hook in the child component needing this access:

import { createContext, useContext } from "react";

...

function Heading({children}){
  var context = useContext(CardContext);

  return (
    <h2 className="Card__heading">
      {children}
    </h2>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the context variable holds whatever value we define in the value prop of the provider value={// whatever you want}, in our case this is just an empty object value={{}}.

The beauty of what we have created so far is that if we where to render <Card.Heading> outside <Card> (which is the provider), the context variable inside <Card.Heading> would be undefined, while if rendered inside, would contain the empty object {}.

Since this part is about scope and not about values available to child components through the use of context, let's create that scope by using the knowledge described above to make a condition check:

Condition check inside the child component
...

function Heading({children}){
  var context = useContext(CardContext);

  if (!context) {
    return (
      <p className="Card__scopeError>
        I want to be inside the Card component!
      </p>
    )
  }

  return (
    <h2 className="Card__heading">
      {children}
    </h2>
  );
}
Enter fullscreen mode Exit fullscreen mode

If we now try to render <Card.Heading> outside <Card>, a p-tag with our "error message" is rendered instead of our h2 which forces us to only use it inside <Card>. Great!

Although if we make a lot of child components we would have to copy/paste the context and the condition check into each and every one of them. That, I don't like very much. While it would work fine, the code would be very wet and not dry enough!

Combining condition check and context with a custom hook

All the code before the return statement inside <Card.Heading> can be boiled down to a single line using a custom hook which makes it a lot cleaner and easier to create new child components.

A custom hook is just a normal function with the benefit of having access to other hooks whether they are Reacts built in hooks like useState, useEffect, useRef and so on, or other custom hooks.

There is one important rule to creating custom hooks and that is to start your function names with the word "use":

function useObjectState(initialValue){
  var [state, setState] = useState(initialValue);

  return {state, setState};
}
Enter fullscreen mode Exit fullscreen mode

If you do this:

function objectState(initialValue){
  var [state, setState] = useState(initialValue);

  return {state, setState};
}
Enter fullscreen mode Exit fullscreen mode

you will get the following error:

React Hook "useState" is called in function "objectState" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter  react-hooks/rules-of-hooks
Enter fullscreen mode Exit fullscreen mode

Okay then, let's create this custom hook (the hook is just copied from Kent C. Dodds' code. Link is at the top or click here):

import { createContext, useContext } from "react";

...

function useCardContext(){
  var context = useContext(CardContext);

  if (!context) {
    throw new Error("Child components of Card cannot be rendered outside the Card component!");
  }

  return context;
}


function Card({children}){
  ...
Enter fullscreen mode Exit fullscreen mode

The sweet thing now is that every child component only have to use this custom hook, and the scope + context still works fine:

...

function useCardContext(){
  ...
}

function Heading({children}){
  var context = useCardContext();

  return (
    <h2 className="Card__heading">
      {children}
    </h2>
  );
}

...
Enter fullscreen mode Exit fullscreen mode

That's it!

Well, almost anyway, we are still not using any value through the context, but trust me, it will work. Don't believe me? Okay then, let's do that next, shall we:

State management

Say we wanted a simple button in our card that when clicked, toggled the border color around our entire card and maybe the text color of our heading also toggles (why, because reasons!?).

How would we do that?

Well let's create the button component first:

...

function Heading({children}){
  var context = useCardContext();
  ...
}

function Button({children}){
  var context = useCardContext();

  return (
    <button className="Card__button">
      {children}
    </button>
  );
}
Card.Button = Button;

...
Enter fullscreen mode Exit fullscreen mode

and use it:

<Card>
  <Card.Heading>My title</Card.Heading>
  <Card.Button>Toggle</Card.Button>
</Card>
Enter fullscreen mode Exit fullscreen mode

The button needs some state handling, but as a rule of thumb; whenever we need to share state between our parent or child components, we should declare it at the parent level (the outer most component), in our case <Card> and then share that state with the other child components through the context. Since we have already created our context, the sharing is just super easy, so let's add that state and the context value (provider value):

import { createContext, useContext, useState } from "react";

...

function Card({children}){
  var [toggled, setToggled] = useState(false);

  return (
    <CardContext.Provider value={{toggled, setToggled}}>
      ...
    </CardContext.Provider>
  );
}

...
Enter fullscreen mode Exit fullscreen mode

What we just did was to create a state with useState in the top level component (<Card>) and added toggled and setToggled to the value prop of its provider (<CardContext.Provider value={{toggled, setToggled}}>).

Did you notice how I "changed" the destructured array to an object with toggled and setToggled as properties and passed that object in as the value for the provider? I want to be able to only "grab" the values I need inside the child components, for example in <Card.Button> we need setToggled to toggle the state in our onClick event, so we just "grab" setToggled from the context:

...

function Button({children}){
  var {setToggled} = useCardContext();

  return (
    <button
      className="Card__button"
      onClick={() => setToggled(prev => !prev)}
    >
      {children}
    </button>
  );
}
Card.Button = Button;

...
Enter fullscreen mode Exit fullscreen mode

I like the destructuring syntax, where we only "pull out" the things we need var {setToggled} = useCardContext();.
Had we used the array as the value, we had to do this: var [toggled, setToggled] = useCardContext();, which would have left toggled as an unused variable.
You could also use the context variable from before, but be aware of the dot syntax you would then have to use (onClick={() => context.setToggled(prev => !prev)}).

For the border to toggle in <Card> we just use the defined toggled state to toggle a CSS class on the div:

...

function Card({children}){
  var [toggled, setToggled] = useState(false);

  return (
    <CardContext.Provider value={{toggled, setToggled}}>
      <div className={toggled ? "Card Card--highlight" : "Card"}>
        {children}
      </div>
    </CardContext.Provider>
  );
}

...
Enter fullscreen mode Exit fullscreen mode

Last thing we need is to make our heading also toggle color, but here we need to "grab" toggled from the context:

...

function Heading({children}){
  var {toggled} = useCardContext();

  return (
    <h2 className={
      toggled
        ? "Card__heading Card__heading--highlight"
        : "Card__heading"}
    >
      {children}
    </h2>
  );
}

...
Enter fullscreen mode Exit fullscreen mode

There you have it. You can now manage state inside your component and share it with the rest of your child components, without ever exposing it to the outside. As Ryan Florence says in his talk (link in the top or go to the video here):

There is state that lives inside of this system.
It's not application state. This isn't stuff that we wanna put over in Redux.
It's not component state because my component has its own state here.
This is its own little system, its own little world of components that has some state that we need to shuffle around.

So in compound component systems, you can create state that only lives inside this system, which in my opinion is very powerful.

The power of compound components

Compound components are super powerful, and if you read or have read this tutorial, you will see that I mention this a lot, and that's because they are both flexible and expandable, but also once you understand this pattern they are very easy to create, use and work with.

Flexibility

Did you notice that each of our child components (<Card.Heading> and <Card.Button>) only holds a single html (jsx) element? This is one of the things that makes the compound component pattern so very powerful, because now your <Card> component just became very flexible, for example you can do this if you want:

<Card>
  // Who says the button should'nt be above the title?
  // Well you do...! You decide where it should go.
  <Card.Button>Toggle</Card.Button>
  <Card.Heading>My title</Card.Heading>
</Card>
Enter fullscreen mode Exit fullscreen mode

You can also define props/attributes to each component freely, one thing that is harder to do if you have one component with multiple div's (or other element types) that each need some attribute.

I'll admit, without using the compound component pattern, the component will look so much simpler:

<Card title="My title" button={true} />
Enter fullscreen mode Exit fullscreen mode

but who now decides which order the title and button is rendered in? How would we add inline styling to the title and the button? What about flexible className's? Should we add a prop to place the button above? Something like this:

<Card
  style={{border: "2px solid blue"}}
  className="MyCard"
  title="My title"
  titleClass="MyTitle"
  titleStyle={{color: "blue"}}
  button={true}
  buttonAbove={true}
  buttonClass="MyButton"
  buttonStyle={{border: "1px dotted blue"}}
/>
Enter fullscreen mode Exit fullscreen mode

This is just plain awful and, well, not that simple anymore!

Imagine having much more than the title- and the button elements, how would you control the order then? The inline styles, className, etc.? A gigantic amount of props and sooo many if statements... No thanks!

Compound components helps tremendeously with this problem.
Not only is it easier to customize the look, feel and behaviour of your component when using it, but the process of creating the component is also so much easier by using this simple and structural pattern.

Which leads me to the next powerful thing I want to talk about:

Expandability

How hard is it then to add new features to our compound component?

Well, the short answer is: SUPER FREAKIN' EASY!

Let's do an example:

Say we want a flexible image. One where we can decide if it's a normal image that we just insert where we need it, or it is styled differently for example an avatar and maybe the option to insert an image as a background image, whatever we want, really.

Let's try:

...

function Image({src, alt, type}){
  useCardContext();

  return (
    <img
      className={`Card__image${type
        ? " Card__image--" + type
        : ""}`}
      src={src}
      alt={alt}
    />
  );
}
Card.Image = Image;

...
Enter fullscreen mode Exit fullscreen mode

usage:

<Card>
  <Card.Heading>My title</Card.Heading>
  <Card.Image
    src="/path/to/image.jpg"
    alt="Our trip to the beach"
  />
  <Card.Button>Toggle</Card.Button>
</Card>
Enter fullscreen mode Exit fullscreen mode

or:

<Card>
  <Card.Image
    src="/path/to/avatar-image.jpg"
    alt="This is me"
    type="avatar"
  />
  <Card.Heading>My title</Card.Heading>
  <Card.Button>Toggle</Card.Button>
</Card>
Enter fullscreen mode Exit fullscreen mode

Off course you would need proper styling for Card__image--avatar and any other type you pass in.

So whenever you need a new feature, just add it as a subcomponent, it's that simple.
If you want scope, just use the custom context hook.
If you need state, just create the state in the top level component and pass it through the context.
Remember when passing a value through context as an object, this is flexible in itself, since you can just add new properties when needed:

...

function Card({children}){
  var [toggled, setToggled] = useState(false);
  var [something, setSomething] = useState(null);

  return (
    <CardContext.Provider
      value={{
        toggled,
        setToggled,
        something,
        setSomething
      }}
    >
      ...
    </CardContext.Provider>
  );
}

...
Enter fullscreen mode Exit fullscreen mode

That's all folks. Hope you got some insights to the power of compound components and how easy it really is to use and create...

Discussion (5)

Collapse
brunolucena profile image
Bruno A Lucena

Great article man, it really helped me understanding this pattern better than the other articles.
Really well explained step by step.

Collapse
gohulutech profile image
gohulutech

Dude, great article. Pretty simple for when you are just getting familiar with useMemo and useCallback. Thanks.

Collapse
bqardi profile image
Sune Seifert Author

You're very welcome. Thanks for the comment...

Collapse
wuarmin profile image
Armin

Do I really need useCallback or useMemo, or do I need them only if I have performance issues?

Collapse
bqardi profile image
Sune Seifert Author

useCallback and useMemo are not required to implement this pattern. They can surely be used in this pattern like all other hooks, including custom hooks. Basically the only requirement for you to use the compound component pattern is a slight shift in thinking (and understanding it off course).