During development, we face some design patterns in React. Compound Components is one of the most important and frequently used Design Pattern in React. Let us create a Expandable Accordion component using React.
Compound Components are components that are made up of two or more components which cannot be used without it's parent.
A select box is an example of it.
Intially, we set up the Expandable component. Here is the code that goes along with it.
import React, {createContext} from React;
const ExpandableContext = createContext();
const {Provider} = ExpandableContext;
const Expandable = ({children}) => {
return <Provider>{children}</Provider>
}
export default Expandable;
The following things are happening here
- ExpdandableContext is created,
- The Provider is desctructured from the ExpandableContext
- In the end, we are just creating an Expandable Component and returning the JSX with the Provider that displays the children passed to the Expandable component
Now we have to introduce state for the expanded accordion and even create a toggle function for it.
const Expandable = ({children}) => {
/**
* State to update the expanded behaviour
*/
const [expanded, setExpanded] = useState(false);
/**
* Method for toggling the expanded state
*/
const toggle = setExpanded(prevExpanded => !prevExpanded);
return <Provider>{children}</Provider>
}
Now the toggle callback function will be invoked by the expandable header and it shouldn't change every time or re-render. Hence, we can memoize the callback as follows.
After this, we need to pass these - toggle function and expanded to the provider. Hence we write this line:
const value = { expanded, toggle }
and to prevent the re-rendering of value every time, we use useMemo for preserving the object on every render.
const value = useMemo(()=> {expanded, toggle}, [expnded, toggle]);
Providing flexibility to the external user to provide custom functionality after expansion
At times, it will be the requirement to provide custom functionality to the user after the accordion is expanded. In this case we can follow the below pattern.
For class components we can do this using a callback, however for functional components we need to do this with useeffect and run this only when the functional component has already been mounted (it should not run when the component is mounted every time).
/**
* Check for mounting
*/
const componentJustMounted = useRef(true);
/**
* Function to call when the expanded state is altered tp true,
* that is when the expansion happens.
*/
useEffect(()=> {
if(!componentJustMounted.current){
onExpand(expanded);
}
componentJustMounted.current = false
}, [expanded])
We are using a useRef as it will return a reference which will be preserved during render cycles. Initially it is set to true. We only make it false when the callback is executed with the expanded prop passed to it.
Hence the whole component Expandable.js looks like this:
import React, {createContext, useState, useCallback, useRef, useEffect} from 'react';
const ExpandableContext = createContext();
const {Provider} = ExpandableContext;
const Expandable = ({children}) => {
/**
* State to update the expanded behaviour
*/
const [expanded, setExpanded] = useState(false);
/**
* Check for mounting
*/
const componentJustMounted = useRef(true);
/**
* Function to call when the expanded state is altered tp true,
* that is when the expansion happens.
*/
useEffect(()=> {
if(!componentJustMounted.current){
onExpand(expanded);
}
componentJustMounted.current = false
}, [expanded, onExpand])
/**
* Method for toggling the expanded state
*/
const toggle = useCallback(() =>
setExpanded(prevExpanded => !prevExpanded), []
);
const value = useMemo(()=> {expanded, toggle}, [expanded, toggle])
return <Provider value={value}>{children}</Provider>
}
export default Expandable;
Building Child Components
The three components of the body, header and icon are as follows.
Header.js
import React, { useContext } from 'react'
import { ExpandableContext } from './Expandable'
const Header = ({children}) => {
const { toggle } = useContext(ExpandableContext)
return <div onClick={toggle}>{children}</div>
}
export default Header;
Here we just try and access the toggle and on click we toggle the body on click of the div. This is the by default feature of accordion.
For Body,
Body.js
import { useContext } from 'react'
import { ExpandableContext } from './Expandable'
const Body = ({ children }) => {
const { expanded } = useContext(ExpandableContext)
return expanded ? children : null
}
export default Body
In the body, we check if the expanded property is true or not. If it is true, we set the body to the props.children passes to it, otherwise we return null (since the body is not expanded).
For icon, we can use Icon.js which looks like this:
Icon.js
// Icon.js
import { useContext } from 'react'
import { ExpandableContext } from './Expandable'
const Icon = () => {
const { expanded } = useContext(ExpandableContext)
return expanded ? '-' : '+'
}
export default Icon
For expanded body, we show a - sign and for contracted body, we show, +.
After adding these logics, let us add just the styles in the each of these elements and finally the components look like this.
Expandable.js
import React, {
createContext,
useState,
useCallback,
useRef,
useEffect,
useMemo,
} from "react";
export const ExpandableContext = createContext();
const { Provider } = ExpandableContext;
const Expandable = ({ onExpand, children, className = "", ...otherProps }) => {
const combinedClasses = ["Expandable", className].filter(Boolean).join("");
/**
* State to update the expanded behaviour
*/
const [expanded, setExpanded] = useState(false);
/**
* Check for mounting
*/
const componentJustMounted = useRef(true);
/**
* Method for toggling the expanded state
*/
const toggle = useCallback(
() => setExpanded((prevExpanded) => !prevExpanded),
[]
);
/**
* Function to call when the expanded state is altered tp true,
* that is when the expansion happens.
*/
useEffect(() => {
if (!componentJustMounted.current) {
onExpand(expanded);
}
componentJustMounted.current = false;
}, [expanded, onExpand]);
const value = useMemo(() => ({ expanded, toggle }), [expanded, toggle]);
return (
<Provider value={value}>
<div className={combinedClasses} {...otherProps}>{children}</div>
</Provider>
);
};
export default Expandable;
Body.js
// Body.js
import './Body.css'
import { useContext } from 'react'
import { ExpandableContext } from './Expandable'
const Body = ({ children , className='',... otherProps}) => {
const { expanded } = useContext(ExpandableContext);
const combinedClassName = ['Expandable-panel', className].filter(Boolean).join('');
return expanded ?
<div className ={combinedClassName} {...otherProps} >{children}</div> : null
}
export default Body
Header.js
import React, { useContext } from 'react'
import { ExpandableContext } from './Expandable'
import './Header.css';
const Header = ({className='', children, ...otherProps}) => {
const combinedClassName = ['Expandable-trigger',className].filter(Boolean).join('');
const { toggle } = useContext(ExpandableContext)
return <button className={combinedClassName} {...otherProps}
onClick={toggle}>{children}</button>
}
export default Header;
Icon.js
import { useContext } from 'react'
import { ExpandableContext } from './Expandable'
const Icon = ({ className='', ...otherProps}) => {
const { expanded } = useContext(ExpandableContext);
const combinedClassName = ['Expandable-icon', className].join('');
return <span className={combinedClassName} {...otherProps}>{expanded ? '-' : '+'}</span>
}
export default Icon
You can view its behaviour at https://officialbidisha.github.io/exapandable-app/
and the github code is available at https://github.com/officialbidisha/exapandable-app
This is how compound components work. We cannot use the Expandable component without the Header, Icon and Body and vice versa. We have successfully learnt a design pattern now.
Happy learning!
Top comments (9)
If you would like to add syntax coloring to your code:
Markdown - how to make blocks of React code (syntax highlighting - github, dev.to)
I personally struggeled with this.
At the beginning of the src code line after 3 x the grave accent sign, we need to add jsx.
Like so:
Thanks for putting the source code link. Its helped me understand the whole picture in a single shot. Nice article. A new thing learned. Keep writing ✍️. Keep sharing your knowledge. Wish you all the best.
Thank you so much. Again, you are the inspiration.
Thank you so much for this. Much needed. Going through this pattern. Thank you for your contributions.
Also wanted to ask, why did you convert the inline styles.
I applied the inline style to demonstrate the purpose of ...otherProps.
Thank you 🙏
Is 2 the minimum number of components required to build compound components?
Yes right. You are right.