Master React Compound Components: Build a Reusable Counter

Bassey OliverBassey Oliver
10 min read

As a React developer, you’ll often build components that need to adapt across different parts of your app. The compound component pattern helps you do that perfectly.

At its core, the idea stays simple: You build a group of components that work together as one unit. Each piece knows its role and shares one common state behind the scenes.

And the interesting part? You can fix a big headache in React called prop drilling (more on this) when combined with React’s Context API. This keeps your code simple as your app grows

In this article, you’ll learn how to use it with React’s Context API to build a simple but powerful counter app that is flexible and reusable step by step.

Let’s get started.

Prerequisites:

You will need the following to follow along with this article:

  • Basic understanding of React(Although I will explain the necessary concepts).

  • Familiarity with the command line interface(CLI).

  • A pc with a code editor installed (preferably Visual Studio Code).

  • node.js installed.

  • An updated web browser (preferably Chrome).

What is a React Component?

First things first, what’s even a component?

Think about the screen you're looking at right now. In React, what you see is built from small, reusable parts called components. It’s the building block of any User Interface (UI).

Each component is just a JavaScript function that returns something called JSX. It looks like HTML, but it works inside JavaScript. React takes that JSX, compiles it, and turns it into real HTML in the browser.

For this project, we’ll use functional components, one of the cleanest and most popular ways to write components in React today.

Here’s a quick example of what a functional component looks like:

function MyComponent() {
  return (
    <div>
      <h1>My first component</h1>
    </div>
  );
}

The component’s name is MyComponent and it returns a JSX which is the h1 element wrapped in a root div element.

Note: The component’s name must start with a capital letter, and should always return a single root element (like the div above) that wraps everything (including other components) inside it.
Explore the fundamentals of React components in more detail here.

What is a React Compound Component?

The compound component pattern lets you build React components that work together by sharing one state. It’s made up of a parent component that handles the logic, and the children components that handle what to show or how to act. They rely on each other, so you can’t use one without the other.

Think of it like the select and option elements in HTML. select handles the behavior, while option just sits inside it. On its own, an option doesn’t do much.

That’s exactly how compound components work in React. It’s perfect for UI components like tabs, modals, dropdowns, tables, and pagination.

Compound component when combined with React’s Context API helps to avoid prop drilling. This further keeps your code simple and easy to read.

Here’s what a typical compound setup looks like:

    <Counter>
      <Counter.Label>Counter App</Counter.Label>
      <Counter.IncreaseCount icon="+" />
      <Counter.Count />
      <Counter.DecreaseCount icon="-" />
    </Counter>

Don’t worry if the syntax looks confusing now, it’ll make more sense as we go.

What is prop drilling?

In React, props are how components share data. A parent component sends props down to a child, and that child can then use them however it wants.

Sounds simple, right?

If you’ve ever passed props through three, four, or five layers just to reach one tiny child, you know how messy it gets. That’s what we call prop drilling.

It clutters your code and makes it harder to read. And even worse, it can slow down your app because every time that prop changes, the parent and all the middle components re-render, even if they don’t use the prop.

Here’s a quick example to show what I mean:

function App() {
  const userDetails = { name: "Bassey Oliver", title: "Technical Writer" };
  return <AppLayout userDetails={userDetails} />;
}

function AppLayout({ userDetails }) {
  return <Sidebar userDetails={userDetails} />;
}

function Sidebar({ userDetails }) {
  return <UserProfile userDetails={userDetails} />;
}

function UserProfile({ userDetails }) {
  return (
    <div>
      <h1>{userDetails.name}</h1>
      <p>{userDetails.title}</p>
    </div>
  );
}

As you can see, the userDetails object was passed from the App component down to UserProfile, jumping through every level along the way.

Only UserProfile actually needs it, but every component in the chain is forced to handle it. That’s not just annoying, it makes your code harder to manage and debug.

The good news? React’s Context API can help. It gives you a clean way to share data across components. No need to pass props through every layer. Let’s look at how it works.

What is the Context API?

The Context API in React helps you share data across components, without having to pass props down manually through every level of the component tree. Think of it like a broadcaster: it lets any component in your app “tune in” and access the data it shares.

Core parts of the Context API

Here’s what makes up the Context API:

  • Provider: This is the source. It holds the data and gives access to all the components wrapped inside it. You’ll usually place the Provider high in the component tree so its data is available everywhere below.

  • Value: This is the actual data you want to share, like state variables or functions.

  • Consumers: These are the components that need access to the shared data. When the data updates, only the consumers using it will re-render.

How to use context API

Using the Context API follows three basic steps:

  1. Create the context using createContext().

  2. Provide the context by wrapping the parent component with the context’s Provider and passing in the data as a value.

  3. Consume the context using the useContext() hook wherever you need the data.

Let’s revisit the earlier example with prop drilling and clean it up using the Context API.

function App() {
  const userDetails = { name: "Bassey Oliver", title: "Technical Writer" };

  return (
    <UserContext.Provider value={{ userDetails }}>
      <AppLayout />
    </UserContext.Provider>
  );
}

function AppLayout() {
  return <Sidebar />;
}

function Sidebar() {
  return <UserProfile />;
}

function UserProfile() {
  const { userDetails } = useContext(UserContext);

  return (
    <div>
      <h1>{userDetails.name}</h1>
      <p>{userDetails.title}</p>
    </div>
  );
}

See how clean that looks? No more passing prop through multiple layers. The component that needs the data just grabs it directly using useContext().

Building a mini-counter app

Now that you know the basics, let’s build a mini counter app using the compound component pattern. It’ll have two buttons to change the count and two labels for the title and value. We’ll skip CSS to keep things simple.

Setting up the React project

You can set up a React project with Create React App or Vite. Vite is better for larger apps as it’s faster. But we’ll stick with Create React App since our project is small.

Here’s how to do it:

  1. Create a new folder.
    Create an empty folder named counter anywhere you like (your desktop is fine).

  2. Open the folder in your code editor.
    If you're using VS Code, open it and navigate to the folder.

  3. Set up the React project
    In the terminal inside your editor, run the following commands sequentially:

npx create-react-app counter
cd counter
npm start

Here’s what each command does:

  • npx create-react-app counter – sets up a new project folder called counter.

  • cd counter – moves into that new folder.

  • npm start – runs the app in your browser.

Once it loads, you're good to go.

Cleaning up the project file

Whenever you create a new React app, it comes with some default files that you won’t need. We are going to delete these files and start from scratch.

Open your project folder and delete the following files:

  • App.css

  • logo.svg

  • reportWebVitals.js

  • setupTests.js

After that, open up App.js and remove any import statements or code that references those files. This gives us a fresh, clean setup to start building our counter. Let’s create the compound component.

How to create a compound component

There are different ways to build compound components, but I’ll show you a simple and practical method. For this mini project, we’ll keep everything in a single file (App.js). We’ll need two components: App.js which will render the UI, and Counter.js will handle the logic. Finally, We'll follow these four basic steps to create the compound component.

Step 1: Create the Counter Context

const CounterContext = createContext();

Make sure to import createContext from the React library.

Step 2: Create the Parent Component
This will store all the state and logic.

function Counter({ children }) {
  const [counterValue, setCounterValue] = useState(0);

  function increaseCount() {
    setCounterValue((value) => value + 1);
  }

  function decreaseCount() {
    setCounterValue((value) => value - 1);
  }

  return (
    <CounterContext.Provider
      value={{ counterValue, increaseCount, decreaseCount }}
    >
      {children}
    </CounterContext.Provider>
  );
}

Here’s what’s happening in our code:

  • useState(0) creates a counter state and initialized it to 0.

  • increaseCount and decreaseCount are functions that update the state.

  • The CounterContext.Provider shares the state and functions with all child components.

Step 3: Create the Child Components

These components will read the data from the context using the useContext() and trigger changes.

function IncreaseCount({ icon }) {
  const { increaseCount } = useContext(CounterContext);
  return <button onClick={increaseCount}>{icon}</button>;
}

function DecreaseCount({ icon }) {
  const { decreaseCount } = useContext(CounterContext);
  return <button onClick={decreaseCount}>{icon}</button>;
}

function Count() {
  const { counterValue } = useContext(CounterContext);
  return <span>{counterValue}</span>;
}

function Label({ children }) {
  return <span>{children}</span>;
}

Step 4: Wire It All Together

Now, connect the set the child components as properties of the Counter component:

Counter.Label = Label;
Counter.Count = Count;
Counter.IncreaseCount = IncreaseCount;
Counter.DecreaseCount = DecreaseCount;

Yeah, we are almost done. It’s time to use it in App.js.

function App() {
  return (
    <Counter>
      <Counter.Label>Counter App</Counter.Label>
      <Counter.IncreaseCount icon="+" />
      <Counter.Count />
      <Counter.DecreaseCount icon="-" />
    </Counter>
  );
}

Here's what we just did:

  • Wrapped all the child components inside the <Counter> parent.

  • Added text to the <Label> component.

  • Passed icons as props to customize our buttons.

and yeah, that’s it! You can see it working on your browser. As you can see, using the compound component pattern with the Context API kept our code clean and avoided prop drilling.

But then, you might wonder…

What makes our counter component flexible?

As said earlier, compound component helps us build components that are flexible and reusable. Let’s tweak our counter component a bit to illustrate this concept.

function App() {
  return (
    <>
      <Counter>
        <Counter.Label>Welcome to my counter app</Counter.Label>
        <Counter.DecreaseCount icon="Add" />
        <Counter.Count />
        <Counter.IncreaseCount icon="Subtract" />
      </Counter>

      <Counter>
        <Counter.Label>Welcome to my counter app</Counter.Label>
        <Counter.Count />
        <Counter.DecreaseCount icon="⬅" />
        <Counter.IncreaseCount icon="➜" />
      </Counter>
    </>
  );
}

As you can see, we reused the counter component, swapped the button positions, and changed the icons and the label without affecting the logic. That’s the beauty of compound components. They give you flexibility while keeping things clean and organized. This pattern will make you a more powerful React developer (lol).

Here’s the entire code for the application.

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

// step 1: Create a context.
const CounterContext = createContext();

// step 2: create parent Counter Component
function Counter({ children }) {
  const [counterValue, setCounterValue] = useState(0);

  function increaseCount() {
    setCounterValue((value) => value + 1);
  }

  function decreaseCount() {
    setCounterValue((value) => value - 1);
  }
  return (
    <CounterContext.Provider
      value={{ counterValue, increaseCount, decreaseCount }}
    >
      <span>{children}</span>
    </CounterContext.Provider>
  );
}

// step 3: create children component that will help implement similar functionalities
function IncreaseCount({ icon }) {
  const { increaseCount } = useContext(CounterContext);
  return <button onClick={increaseCount}>{icon}</button>;
}

function DecreaseCount({ icon }) {
  const { decreaseCount } = useContext(CounterContext);
  return <button onClick={decreaseCount}>{icon}</button>;
}

function Count() {
  const { counterValue } = useContext(CounterContext);
//we'll use span so they stay on the same line
  return <span>{counterValue}</span>;
}

function Label({ children }) {
  return <span>{children}</span>;
}

// step 4: Wire them together by adding child componenents as properties of the parent component
Counter.Label = Label;
Counter.Count = Count;
Counter.IncreaseCount = IncreaseCount;
Counter.DecreaseCount = DecreaseCount;

function App() {
  return (
    <Counter>
      <Counter.Label>Counter App</Counter.Label>
      <Counter.IncreaseCount icon="+" />
      <Counter.Count />
      <Counter.DecreaseCount icon="-" />
    </Counter>
  );
}

Conclusion

Congrats on making it to the end! By now, you’ve seen how useful the compound component pattern can be. It’s my go-to when I need flexible, reusable components that keep code clean and easy to manage. And when you pair it with the Context API, it becomes even better—no more messy prop drilling.

This pattern shines in real-world features like modals, tabs, accordions, and more.

Now, a quick challenge:

Add a Reset button to the counter app that sets the count back to zero. It’s a simple tweak, but a solid way to lock in what you’ve learned.

Thanks for reading and happy coding!

10
Subscribe to my newsletter

Read articles from Bassey Oliver directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bassey Oliver
Bassey Oliver

I am a highly creative and resourceful Front-End Engineer, with strong analytical skills and demonstrated efforts in paying attention to user's needs, analyzing every detail, and developing modernized solutions for them with maximum efficiency. I possess a strong mastery of front-end technologies such as HTML5, CSS3, Javascript, and React and I proactively keep up with industry trends. More so, I am a technical writer with demonstrated efforts in simplifying complex tasks into words. Check out my articles for intriguing content.