Compound Components Pattern in React

Bidisha Das - Jun 28 '22 - - Dev Community

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!

. . . . . .