In this article, you'll learn how to use React Composition to create reusable and extendable components. This will help you identify opportunities to remove duplicate code and make your application easier to maintain as you hire new engineers for your team. When new features are constantly added without having to modify multiple files you'll, instead, modify a single source.
What Is React Composition?
React Composition is a development pattern based on React's original component model where we build components from other components using explicit defined props or the implicit children
prop.
In terms of refactoring, React composition is a pattern that can be used to break a complex component down to smaller components, and then composing those smaller components to structure and complete your application.
Why Use React Composition?
This technique prevents us from building too many similar components containing duplicate code and allows us to build fewer components that can be reused anywhere within our application, making them easier to understand and maintain for your team.
First, let's start examining the components that we'll be refactoring:
Accordion Component
import React, { useState } from "react"; const Accordion = () => { const [expanded, setExpanded] = useState(false); const toggleExpanded = () => { setExpanded((prevExpanded) => !prevExpanded); }; return ( <div> <button onClick={toggleExpanded}> Header <span>{expanded ? "-" : "+"}</span> </button> {expanded && <div>Content</div>} </div> ); }; export default Accordion;
import React from "react"; import Accordion from "./components/Accordion"; const App = () => { return <Accordion />; }; export default App;
Editable Component
import React, { useState } from "react"; const Editable = () => { const [editable, setEditable] = useState(false); const [inputValue, setInputValue] = useState("Title"); const toggleEditable = () => { setEditable((prevEditable) => !prevEditable); }; const handleInputChange = (e) => { setInputValue(e.target.value); }; return ( <div> {editable ? ( <label htmlFor="title"> Title: <input type="text" id="title" value={inputValue} onChange={handleInputChange} /> </label> ) : ( <>Title: {inputValue}</> )} <button onClick={toggleEditable}>{editable ? "Cancel" : "Edit"}</button> </div> ); }; export default Editable;
import React from "react"; import Editable from "./components/Editable"; const App = () => { return <Editable />; }; export default App;
Let's explore one similarity between these two components.
Problem: Notice how both the Accordion
component and the Editable
component share the same functionality, where both are dependent on a boolean and a function to update that boolean — in other words, a toggle functionality.
Solution: We can use a custom hook that will allow us to reuse this toggle logic in both components, and in any new component added in the future.
Introducing Custom Hooks
The purpose of creating a custom hook is to extract logic from a component and convert it into a reusable hook.
A reusable custom hook is used to avoid creating too many similar components that share the same logic. It also improves the code of your application by removing duplicate code, making your application easier to maintain. When introducing a new feature in your application, creating a custom hook prevents us from implementing that same new feature to each similarly built component. Instead, we can now reuse a custom hook.
Let's create a custom hook named useToggle
that returns a status
state and a toggleStatus
handler function:
import { useState, useCallback, useMemo } from "react"; const useToggle = () => { const [status, setStatus] = useState(false); const toggleStatus = useCallback(() => { setStatus((prevStatus) => !prevStatus); }, []); const values = useMemo( () => ({ status, toggleStatus }), [status, toggleStatus] ); return values; }; export default useToggle;
The toggle logic implemented in useToggle
is useful in the following scenarios:
- Hiding/Displaying a component
- Collapsing/Expanding a component
We can now reuse our new custom hook as many times as needed in any component that will take advantage of using this shared logic.
Refactoring Accordion Component To Use Custom Hook
Let's refactor the Accordion
component to use the useToggle
custom hook:
import React from "react"; import useToggle from "./useToggle"; const Accordion = () => { const { status: expanded, toggleStatus: toggleExpanded } = useToggle(); return ( <div> <button onClick={toggleExpanded}> Header <span>{expanded ? "-" : "+"}</span> </button> {expanded && <div>Content</div>} </div> ); }; export default Accordion;
Refactoring Editable Component To Use Custom Hook
We can also refactor the Editable
component using the same useToggle
custom hook:
import React, { useState } from "react"; import useToggle from "./useToggle"; const Editable = () => { const { status: editable, toggleStatus: toggleEditable } = useToggle(); const [inputValue, setInputValue] = useState("Title"); const handleInputChange = (e) => { setInputValue(e.target.value); }; return ( <div> {editable ? ( <label htmlFor="title"> Title: <input type="text" id="title" value={inputValue} onChange={handleInputChange} /> </label> ) : ( <>Title: {inputValue}</> )} <button onClick={toggleEditable}>{editable ? "Cancel" : "Edit"}</button> </div> ); }; export default Editable;
Refactoring Accordion Component To Use Specialized and Container Components
Let's examine how the header and content are displayed in our new Accordion
to investigate another problem.
Problem: If we wanted to create different variations of the Accordion
, e.g. CarDetailsAccordion
, CommentsAccordion
, PaymentOptionsAccordion
, then we would have to write the same button
element and expanded && content
conditional in each component.
Solution: To avoid having duplicate boiler plate code when displaying the header and content of a variation of an Accordion
, let's create specialized and container components.
Specialized Components
A specialized component is a component that is built from its accepted props to handle one specific case.
Let's create two new specialized components to handle displaying the header and the content of the Accordion
component:
import React from "react"; const AccordionHeader = ({ children, expanded, toggleExpanded }) => { return ( <button onClick={toggleExpanded}> {children} <span>{expanded ? "-" : "+"}</span> </button> ); }; export default AccordionHeader;
import React from "react"; const AccordionContent = ({ children, expanded }) => { return <>{expanded && children}</>; }; export default AccordionContent;
Container Components
A container component, also known as a parent component, is a component that provides the state and behavior to its children components.
We can now refactor the Accordion
component into a container component, and also include our two new specialized components AccordionHeader
and AccordionContent
:
import React from "react"; import useToggle from "./useToggle"; import AccordionHeader from "./AccordionHeader"; import AccordionContent from "./AccordionContent"; const Accordion = ({ children, header }) => { const { status: expanded, toggleStatus: toggleExpanded } = useToggle(); return ( <div> <AccordionHeader expanded={expanded} toggleExpanded={toggleExpanded}> {header} </AccordionHeader> <AccordionContent expanded={expanded}>{children}</AccordionContent> </div> ); }; export default Accordion;
Notice how we are reusing the AccordionHeader
and AccordionContent
components, and this can also be applied to any new component that needs them.
This is how we will use our refactored Accordion
component in our application:
import React from "react"; import Accordion from "./components/Accordion"; const App = () => { return ( <> <Accordion header="Accordion 1"> <div>Content for Accordion 1</div> </Accordion> <Accordion header="Accordion 2"> <div>Content for Accordion 2</div> </Accordion> <Accordion header="Accordion 3"> <div>Content for Accordion 3</div> </Accordion> </> ); }; export default App;
Let's examine our Accordion
component once again.
Problem: Notice how we are passing the expanded={expanded}
prop to AccordionHeader
and to AccordionContent
. If we were to introduce a new specialized component that also needed the expanded={expanded}
prop to be passed, we would also create additional duplicate code.
Solution: We can avoid repeating ourselves by using React's Context API, where we don't have to write and pass the same prop to all of the Accordion
's children components, deeply nested components, or newly added specialized components.
Refactoring The Accordion Component To Use React's Context API
We'll use React's Context API to provide the expanded
state and the toggleExpanded
handler function to the entire Accordion
component tree, making them available to all its children components and newly added children components. This also prevents us from having to manually pass down props when a new prop is introduced:
import React, { createContext } from "react"; import useToggle from "./useToggle"; import AccordionHeader from "./AccordionHeader"; import AccordionContent from "./AccordionContent"; export const AccordionContext = createContext(); const { Provider } = AccordionContext; const Accordion = ({ children, header }) => { const { status: expanded, toggleStatus: toggleExpanded } = useToggle(); const value = { expanded, toggleExpanded }; return ( <Provider value={value}> <div> <AccordionHeader>{header}</AccordionHeader> <AccordionContent>{children}</AccordionContent> </div> </Provider> ); }; export default Accordion;
import React, { useContext } from "react"; import { AccordionContext } from "./Accordion"; const AccordionHeader = ({ children }) => { const { expanded, toggleExpanded } = useContext(AccordionContext); return ( <button onClick={toggleExpanded}> {children} <span>{expanded ? "-" : "+"}</span> </button> ); }; export default AccordionHeader;
import React, { useContext } from "react"; import { AccordionContext } from "./Accordion"; const AccordionContent = ({ children }) => { const { expanded } = useContext(AccordionContext); return <>{expanded && children}</>; }; export default AccordionContent;
Our newly refactored Accordion
component does not affect how we reuse it within our application:
import React from "react"; import Accordion from "./components/Accordion"; const App = () => { return ( <> <Accordion header="Accordion 1"> <div>Content for Accordion 1</div> </Accordion> <Accordion header="Accordion 2"> <div>Content for Accordion 2</div> </Accordion> <Accordion header="Accordion 3"> <div>Content for Accordion 3</div> </Accordion> </> ); }; export default App;
Extending The Accordion Component
Let's say our client would like us to update the -
and +
text that is toggled in our AccordionHeader
. The client would like us to use icons instead. This can now be easily accomplished by introducing a new specialized AccordionIcon
component:
import React, { useContext } from "react"; import { AccordionContext } from "./Accordion"; const AccordionIcon = ({ opened = "-", closed = "+" }) => { const { expanded } = useContext(AccordionContext); return <span>{expanded ? opened : closed}</span>; }; export default AccordionIcon;
And implement it in our AccordionHeader
component:
import React, { useContext } from "react"; import { AccordionContext } from "./Accordion"; import AccordionIcon from "./AccordionIcon"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faAngleDown, faAngleUp } from "@fortawesome/free-solid-svg-icons"; const AccordionHeader = ({ children }) => { const { toggleExpanded } = useContext(AccordionContext); return ( <button onClick={toggleExpanded}> {children} <AccordionIcon opened={<FontAwesomeIcon icon={faAngleUp} />} closed={<FontAwesomeIcon icon={faAngleDown} />} /> </button> ); }; export default AccordionHeader;
Conclusion
By prioritizing reusability from the beginning of our application, we can now easily extend the Accordion
component with new features that our client would like us to implement, and increase our team's speed to deliver those new features. Thus, with the help of React composition, we can provide our teams with more reusable, readable, and extendable components.
Achieving reusability is no easy task, but as we continue to grow and learn with each and every application we build for our clients, we build the experience that is needed to succeed in our careers as software engineers.
Here at Formidable, one of our core values is to provide the highest standards of quality in every line of code we write for our clients. If you would like to hire one of our skilled engineers or designers for your team, please contact us.