Building Compound Components using React Context API and TypeScript

Search for a command to run...

No comments yet. Be the first to comment.
This blog post is fourteenth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 14. This challenge was about optimizing an algorithm that generates exponentially larger and larger strings. I found ...

This blog post is thirteenth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 13. All solutions are in this GitHub repository. Solving Part One Given a list of x, y coordinates representing dots ...

This blog post is twelfth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described on Day 12. All solutions are in this GitHub repository. Solving Part One Given a set of connections between underground caves, ...

This blog post is eleventh in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 11. This day had many similarities to Day 9, and I solved the challenge using similar methods. All solutions are in thi...

This blog post is the tenth in the Advent of Code 2021 series and shows a JavaScript-based solution to the problem described in Day 10. All solutions are in this GitHub repository. Solving Part One Given a set of lines with opening and closing bracke...

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:

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!