Building Compound Components using React Context API and TypeScript

Building Compound Components using React Context API and TypeScript

When creating a React project, you might need to share some state between components of your application. React Context API offers a mechanism for passing data through the component tree without relying on props (also known as props drilling).

I have seen teams implement a lightweight global store for their React application using React Context as an alternative to using a state management library such as Redux.

This article shows how you can build reusable Accordion compound components that rely on React Context to share state and control the visibility of collapsible content.

You can find the completed project on GitHub.

You can see the Accordion in action: Accordion.gif

The building blocks of the Accordion are CollapseButton and ExpandButton, which toggle the visibility of the Collapsible content when clicked.

In the main App file we use the Accordion as follows:

<Accordion>
    <h1>Accordion</h1>
    <Accordion.CollapseButton>Show less</Accordion.CollapseButton>
    <p>
        In pharetra nibh massa, ...
        <Accordion.Collapsible>
            Vestibulum feugiat ...
        </Accordion.Collapsible>
    </p>
    <Accordion.ExpandButton>Show more...</Accordion.ExpandButton>
    <Accordion.CollapseButton>Show less</Accordion.CollapseButton>
</Accordion>;

Since the Accordion exposes compound components, we get the flexibility to place the ExpandButton, CollapseButton, and Collapsible content in any order or configuration we need. From my experience, creating compound components that enable this kind of composability has the benefits of reducing the number of props, complexity, and potential bugs introduced. Thus, it is generally a good practice.

Let's take a look at the implementation of Accordion:

const Accordion: FunctionComponent & AccordionComposition = ({ children }) => {
    const [isExpanded, setIsExpanded] = useState(false);
    const expand = () => setIsExpanded(true);
    const collapse = () => setIsExpanded(false);

    return (
        <AccordionProvider value={{ isExpanded, expand, collapse }}>
            {children}
        </AccordionProvider>
    );
};

If you are wondering about the typings in the first line const Accordion: FunctionComponent & AccordionComposition, you can check out my previous article React Compound Components Typings. It explains how you can define and export Compound Components using TypeScript.

The Accordion component is responsible for storing the isExpanded state, and defining means through which the state can be updated (via expand, collapse functions). As you will see further in this article, the Collapsible, ExpandButton and CollapseButton need to know and modify this state.

This is where React Context comes into play and shines! The AccordionProvider is an important part of the React Context mechanism because it gives children in the tree access to a value or the shared state. For example, the value is an object with three properties: isExpanded, expand and collapse. The type of this object is defined as follows:

interface AccordionContextValue {
    isExpanded: boolean;
    expand: () => void;
    collapse: () => void;
}

To understand how AccordionProvider is defined we must first understand how the AccordionContext is created. We rely on createContext function imported from React.

import { createContext } from "react";

The createContext function accepts a default value as an argument. In this case, I chose the undefined value. We then specify the type of the value passed through the context which can either be AccordionContextValue or the undefined value.

const AccordionContext = createContext<AccordionContextValue | undefined>(undefined);

The AccordionContext exposes a Provider which we can export as AccordionProvider

export const AccordionProvider = AccordionContext.Provider;

According to React Documentation:

Every Context object comes with a Provider React component that allows consuming components to subscribe to context changes.

The Provider component accepts a value prop to be passed to consuming components that are descendants of this Provider. One Provider can be connected to many consumers. Providers can be nested to override values deeper within the tree.

All consumers that are descendants of a Provider will re-render whenever the Provider’s value prop changes.

To consume the value provided through AccordionProvider we can use useContext hook:

import { useContext } from "react";
const context = useContext(AccordionContext);

It is a good idea to check whether we have an undefined context since it is an indication that Collapsible, ExpandButton or CollapseButton were used outside of an Accordion parent. Such a usage is unintended. The useAccordionContext custom hook abstracts away this logic and allows reusing it in all places we need:

export const useAccordionContext = () => {
    const context = useContext(AccordionContext);
    if (!context) {
        throw new Error(
            "Accordion.Collapsible, Accordion.ExpandButton and Accordion.CollapseButton must be used within an Accordion"
        );
    }
    return context;
};

We can then destructure the isExpanded value from useAccordionContext and conditionally render children of the Collapsible component:

const Collapsible: FunctionComponent = ({ children }) => {
    const { isExpanded } = useAccordionContext();
    if (isExpanded) {
        return <>{children}</>;
    } else {
        return null;
    }
};

In the ExpandButton component, we are also using the expand function when someone clicks the button to toggle the visibility of the collapsible content. Besides, we control when ExpandButton is rendered depending on whether isExpanded is false.

const ExpandButton: FunctionComponent = ({ children }) => {
    const { isExpanded, expand } = useAccordionContext();
    if (!isExpanded) {
        return (
            <button className={styles.button} onClick={expand}>
                {children}
            </button>
        );
    } else {
        return null;
    }
};

Similarly, in the CollapseButton we are using the collapse function and also controlling when CollapseButton is rendered depending on isExpanded.

const CollapseButton: FunctionComponent = ({ children }) => {
    const { isExpanded, collapse } = useAccordionContext();
    if (isExpanded) {
        return (
            <button className={styles.button} onClick={collapse}>
                {children}
            </button>
        );
    } else {
        return null;
    }
};

To conclude, React Context API is a powerful tool for passing state through the tree of children. We looked at an example of how to build Accordion compound components that share and modify some state and how we can abstract that mechanism away for consumers of those components.

Thanks for reading!