DEV Community

Cover image for Compound Components Pattern in React
Bidisha Das
Bidisha Das

Posted on

Compound Components Pattern in React

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.

Image description

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;
Enter fullscreen mode Exit fullscreen mode

The following things are happening here

  1. ExpdandableContext is created,
  2. The Provider is desctructured from the ExpandableContext
  3. 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>
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]) 
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (11)

Collapse
lukeshiru profile image
Luke Shiru

Ideally we should try to keep our components simple, and independent of context. I cloned your repo in CodeSandbox and did some updates on it. The changes I did:

  1. Added the classnames package to make the className combination easier instead of reimplementing it in every component.
  2. Added JSDocs types to make DX a little bit better and detect bugs.
  3. Removed children on extracted props in the header because that's passed with ...props.
  4. Changed regular CSS with CSS modules.
  5. Moved some inline styles to CSS modules.
  6. Got completely rid of all the unnecessary hooks and context. Made into Functional Components.

The end result:

There are some scenarios in which you might have a way more complex state you want to deal with in several components, for that my recommended approach is something like the paired hook pattern in which you create a custom hook to deal with the complexity, but you still keep your components stateless, simple and easy to test.

Cheers!

Collapse
officialbidisha profile image
Bidisha Das Author

Thank you so much for this. Much needed. Going through this pattern. Thank you for your contributions.

Collapse
officialbidisha profile image
Bidisha Das Author • Edited on

Also wanted to ask, why did you convert the inline styles.
I applied the inline style to demonstrate the purpose of ...otherProps.

Thread Thread
lukeshiru profile image
Luke Shiru

Mainly because generally is better to just use classnames for styles (modular or atomic). You can still demonstrate that using for example aria labels. Still is a great thing that you are doing that already. There are lots of folks that don't pass the rest props to the underlying element, loosing a lot of extensibility, but you did 😊

Thread Thread
officialbidisha profile image
Bidisha Das Author

Thank you πŸ™

Collapse
amankumarsingh01 profile image
Aman Kumar Singh

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.

Collapse
pcjmfranken profile image
Peter Franken • Edited on

Like so:

# Markdown

Space, the final frontier. These are the voyages of the Starship Enterprise. Its five-year mission: to explore strange new worlds, to seek out new life and new civilizations, to boldly go where no man has gone before.

Code block:

`​`​`jsx
import React from 'react'

export default function Hai({ children }){
  return '<h1>{children}</h1>'
}
`​`​`

Fin.
Enter fullscreen mode Exit fullscreen mode
Collapse
tsgoswami profile image
Trishnangshu Goswami

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.

Collapse
officialbidisha profile image
Bidisha Das Author

Thank you so much. Again, you are the inspiration.

Collapse
tsgoswami profile image
Trishnangshu Goswami

Is 2 the minimum number of components required to build compound components?

Collapse
officialbidisha profile image
Bidisha Das Author

Yes right. You are right.