Unlocking Flexibility: Mastering Compound Components in React

Photo by Syed Ahmad on Unsplash

Unlocking Flexibility: Mastering Compound Components in React

In this blog, let’s learn an important React Design Pattern — Compound Components. We will understand what this pattern is all about by making a reusable Accordion Component.

What are Compound Components?

Compound Component is a powerful React Design Pattern to make reusable components, where the idea is to have two or more components work together to accomplish a useful task. Typically we will have one parent component and at least one child component. We combine these components together to provide a more flexible component API. These components internally will have its own state which the components within the parent component can access and decide on how to render. This gives a nice way to express relationships between components.

<select> and <option> API in HTML is an example of compound components. You see, individually <select> and <option> by themselves are not very useful, but when combined together in a logical way, it becomes a powerful component.

Now that we theoretically know what a compound component is, let’s now solidify our understanding by implementing an Accordion component.

<>
  <Accordion>
    <AccordionItem>
      <AccordionButton>
        <div>Click Me !</div>
      </AccordionButton>
      <AccordionPanel>
        <p>This is panel 1</p>
      </AccordionPanel>
    </AccordionItem>
    <AccordionItem>
      <AccordionButton>
        <div>Click Me !</div>
      </AccordionButton>
      <AccordionPanel>
        <p>This is panel 2</p>
      </AccordionPanel>
    </AccordionItem>
  </Accordion>
</>;

This component has Accordion as a parent component. This component will have AccordionItem as its child component, each AccordionItem is a content wrapper for the Accordion content we would want to have. AccordionButton is a component that will handle the click event to toggle the Accordion from open to close and vice-verca. AccordionPanel component is where all the actual content would go and would be toggled open and close by the AccordionButton.

Now, we have an idea of how the Accordion API should look like, let’s start the implementation with the basic structure. One thing which is common among these components is the ability to accept children.

import { createContext, PropsWithChildren } from "react";



type AccordionProps = {};
const Accordion = ({}: PropsWithChildren<AccordionProps>) => {
  return <div>{children}</div>;
};

type AccordionItemProps = {};
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
  return <div className="accordion-item accordion-item-style">{children}</div>;
};

type AccordionButtonProps = {};
const AccordionButton = ({
  children,
}: PropsWithChildren<AccordionButtonProps>) => {
  return <div className="accordion-button">{children}</div>;
};

type AccordionPanelProps = {};
const AccordionPanel = ({
  children,
}: PropsWithChildren<AccordionPanelProps>) => {
  return <div>{children}</div>;
};

export { Accordion, AccordionItem, AccordionButton, AccordionPanel };
import {
  Accordion,
  AccordionButton,
  AccordionItem,
  AccordionPanel,
} from "./Accordion";
import "./styles.css";

export default function App() {
  return (
    <div>
      <Accordion>
        <AccordionItem>
          <AccordionButton>
            <div>Click Me!</div>
          </AccordionButton>
          <AccordionPanel>
            <p>This is accordion panel 1</p>
          </AccordionPanel>
        </AccordionItem>
        <AccordionItem>
          <AccordionButton>
            <div>Click Me!</div>
          </AccordionButton>
          <AccordionPanel>
            <p>This is accordion panel 1</p>
          </AccordionPanel>
        </AccordionItem>
      </Accordion>
    </div>
  );
}

With this structure in place, the output will look like something below.

Let’s understand what features this Accordion component should have:

  1. I want the Accordion component to have the ability to optionally keep item or items panel to be open by default.

  2. I want the Accordion component to have the ability to collapse all the items to be toggled to a closed state, or at any given time one accordion panel will be always open.

  3. I want the Accordion component to have the ability to have multiple accordion panels open or closed at a time, or at any given time only one panel will be open.

We can achieve these by declaring props for each of the above as below:

type AccordionIndex = number | number[];

type AccordionProps = {
  //   A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
  defaultIndex?: AccordionIndex;
  //   Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
  collapisble?: boolean;
  //   Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
  multiple?: boolean;
};
const Accordion = ({
  children,
  defaultIndex,
  collapisble = false,
  multiple = false,
}: PropsWithChildren<AccordionProps>) => {
  return <div>{children}</div>;
};

Notice the type for defaultIndex is AccordionIndex which is either a number or number[]. This is because, we can pass it based on whether we want to keep one panel open by default or more than one panel, in which case we can pass the indices as an array of numbers.

We need some state to be accessible by all child components that would keep track of index or indices of open accordion panels, let’s call this openPanels. We also need a callback that would be invoked when we toggle any accordion panel.

Since we want this to be accessible within all child components, we can have a context that would store this and through the provider, React can make these accessible to all child components.

type AccordionIndex = number | number[];

type AccordionCtxt = {
  openPanels: AccordionIndex;
  onSelectPanel: (index: number) => void;
};
const AccordionContext = createContext<AccordionCtxt | undefined>(undefined);

type AccordionProps = {
  //   A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
  defaultIndex?: AccordionIndex;
  //   Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
  collapsible?: boolean;
  //   Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
  multiple?: boolean;
};

const getInitialOpenPanelState = (
  multiple: boolean,
  collapsible: boolean,
  defaultIndex?: AccordionIndex
) => {
  if (defaultIndex != null) {
    if (multiple) {
      return Array.isArray(defaultIndex) ? defaultIndex : [defaultIndex];
    } else {
      return Array.isArray(defaultIndex) ? defaultIndex[0] ?? 0 : defaultIndex!;
    }
  } else {
    if (collapsible) {
      return multiple ? [] : -1;
    }
    return multiple ? [0] : 0;
  }
};

const Accordion = ({
  children,
  defaultIndex,
  collapsible = false,
  multiple = false,
}: PropsWithChildren<AccordionProps>) => {
  const initialOpenPanelsVal = getInitialOpenPanelState(
    multiple,
    collapsible,
    defaultIndex
  );
  const [openPanels, setOpenPanels] =
    useState<ReturnType<typeof getInitialOpenPanelState>>(initialOpenPanelsVal);

  const onSelectPanel = useCallback(
    (index: number) => {
      setOpenPanels((prevOpenPanels) => {
        if (multiple) {
          prevOpenPanels = prevOpenPanels as number[];
          if (prevOpenPanels.includes(index)) {
            if (prevOpenPanels.length > 1 || collapsible) {
              return prevOpenPanels.filter((i) => i !== index);
            }
          } else {
            return [...prevOpenPanels, index].sort();
          }
        } else {
          prevOpenPanels = prevOpenPanels as number;
          return prevOpenPanels === index && collapsible ? -1 : index;
        }
        return prevOpenPanels;
      });
    },
    [collapsible, multiple]
  );
  return (
    <AccordionContext.Provider value={{ openPanels, onSelectPanel }}>
      <div>{children}</div>
    </AccordionContext.Provider>
  );
};

To make the Accordion API really simple, we need to somehow track what elements are children of Accordion component - here we want to only track AccordionItem as its child or descendant. We could have another prop called as accordionData on the Accordion and based on it we could render AccordionItem with index as its prop that would track it, but we want to make it flexible and not dependent on any prop. It should handle it internally whenever AccordionItem is added.

We can make this happen by registering the component as descendant of Accordion and make the descendants accessible to all its child component by using context. We extract all this logic into a hooks.

type Descendant = {
  element: HTMLElement;
  index: number;
};

const useDescendants = () => {
  const [descendants, setDescendants] = useState<Descendant[]>([]);

  const registerDescendant = useCallback((element: HTMLElement) => {
    setDescendants((previousDescendants) => {
      if (
        !previousDescendants.find(
          (descendant) => descendant.element === element
        )
      ) {
        const updatedDescendats = [
          ...previousDescendants,
          { element, index: previousDescendants.length },
        ];

        return updatedDescendats.map((descendants, index) => {
          return {
            ...descendants,
            index,
          };
        });
      }

      return previousDescendants;
    });
  }, []);

  const unregisterDescendant = useCallback((element: HTMLElement) => {
    setDescendants((previousDescendants) =>
      previousDescendants.filter((descendant) => descendant.element !== element)
    );
  }, []);

  return { descendants, registerDescendant, unregisterDescendant };
};

const useDescendant = (
  ref: RefObject<HTMLElement>,
  context: ReturnType<typeof useDescendants>
) => {
  const { descendants, registerDescendant, unregisterDescendant } = context;

  useLayoutEffect(() => {
    if (ref.current) {
      registerDescendant(ref.current);
      return () => unregisterDescendant(ref.current);
    }
  }, []);

  const index = descendants.findIndex(
    (descendant) => descendant.element === ref.current
  );
  return index;
};

const DescendantContext = createContext<ReturnType<
  typeof useDescendants
> | null>(null);

We then have to wrap the component with Descendent Provider to be accessible by the useContext hook.

type AccordionProps = {
  //   A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
  defaultIndex?: AccordionIndex;
  //   Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
  collapsible?: boolean;
  //   Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
  multiple?: boolean;
};

const Accordion = ({
  children,
  defaultIndex,
  collapsible = false,
  multiple = false,
}: PropsWithChildren<AccordionProps>) => {
  const initialOpenPanelsVal = getInitialOpenPanelState(
    multiple,
    collapsible,
    defaultIndex
  );
  const [openPanels, setOpenPanels] =
    useState<ReturnType<typeof getInitialOpenPanelState>>(initialOpenPanelsVal);

  const onSelectPanel = useCallback(
    (index: number) => {
      setOpenPanels((prevOpenPanels) => {
        if (multiple) {
          prevOpenPanels = prevOpenPanels as number[];
          if (prevOpenPanels.includes(index)) {
            if (prevOpenPanels.length > 1 || collapsible) {
              return prevOpenPanels.filter((i) => i !== index);
            }
          } else {
            return [...prevOpenPanels, index].sort();
          }
        } else {
          prevOpenPanels = prevOpenPanels as number;
          return prevOpenPanels === index && collapsible ? -1 : index;
        }
        return prevOpenPanels;
      });
    },
    [collapsible, multiple]
  );

  const descsendantContext = useDescendants();

  return (
    <DescendantContext.Provider value={descsendantContext}>
      <AccordionContext.Provider value={{ openPanels, onSelectPanel }}>
        <div>{children}</div>
      </AccordionContext.Provider>
    </DescendantContext.Provider>
  );
};

useDescendants returns the context that we want to make it available to the child components and useDescendant returns an index of the registered descendant. Since we want to track AccordionItem as descendant of Accordion, we will use this hook in AccordionItem component.

Let’s understand AccordionItem implementation. Before that we will declare the states of the panels as enum.

enum AccordionStates {
  Open = "OPEN",
  Collapsed = "COLLAPSED",
}

Each AccordionItem will be either of the above AccordionStates, we need to store this information along with the index of AccordionItem and make it accessible to its child, And we will do this with the help of context in React.

enum AccordionStates {
  Open = "OPEN",
  Collapsed = "COLLAPSED",
}

type AccordionItemContext = {
  index: number;
  state: AccordionStates;
};

const AccordionItemContextValue = createContext<
  AccordionItemContext | undefined
>(undefined);

type AccordionItemProps = {};
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
  return <AccordionItemContextValue.Provider value={{index:0,state:AccordionStates.Open}}>
         <div className="accordion-item accordion-item-style">{children}</div>
     </AccordionItemContextValue.Provider>;
};

We will create few more hooks to access the context value of all the contexts we have created till now.

const useAccordionContext = () => {
  const ctxt = useContext(AccordionContext);
  if (!ctxt) {
    throw new Error("");
  }

  return ctxt;
};

const useDescendantsContext = () => {
  const ctxt = useContext(DescendantContext);
  if (!ctxt) {
    throw new Error("");
  }

  return ctxt;
};

const useAccordionItemContext = () => {
    const ctxt = useContext(AccordionItemContextValue);
    if (!ctxt) {
      throw new Error();
    }

    return ctxt;
  };

And use it when needed.

const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
  const ref = useRef<HTMLDivElement>(null);
  const ctxt = useDescendantsContext();
  const index = useDescendant(ref as RefObject<HTMLDivElement>, ctxt);
  const { openPanels } = useAccordionContext();
  const state =
    (Array.isArray(openPanels)
      ? openPanels.includes(index) && AccordionStates.Open
      : openPanels === index && AccordionStates.Open) ||
    AccordionStates.Collapsed;
  return (
    <AccordionItemContextValue.Provider value={{ index, state }}>
      <div ref={ref} className="accordion-item accordion-item-style">
        {children}
      </div>
    </AccordionItemContextValue.Provider>
  );
};

That was about AccordionItem component.

AccordionButton is now really simple, we have already made the logic, we now just have to use the logic and add the onClick event to use this logic.

type AccordionButtonProps = {};
const AccordionButton = ({
  children,
}: PropsWithChildren<AccordionButtonProps>) => {
  const { onSelectPanel } = useAccordionContext();
  const { index } = useAccordionItemContext();

  return (
    <div onClick={() => onSelectPanel(index)} className="accordion-button">
      {children}
    </div>
  );
};

And the AccordionPanel as well, we just have to extract the state from AccordionItem context and based on the state, toggle the panel.

type AccordionPanelProps = {};
const AccordionPanel = ({
  children,
}: PropsWithChildren<AccordionPanelProps>) => {
  const { state } = useAccordionItemContext();
  return (
    <div hidden={state !== AccordionStates.Open} className="accordian-panel">
      {children}
    </div>
  );
};

With this we have completed the full implementation of Accordion component. The full code is as below:

import {
  createContext,
  PropsWithChildren,
  RefObject,
  useCallback,
  useContext,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

type Descendant = {
  element: HTMLElement;
  index: number;
};
const useDescendants = () => {
  const [descendants, setDescendants] = useState<Descendant[]>([]);

  const registerDescendant = useCallback((element: HTMLElement) => {
    setDescendants((previousDescendants) => {
      if (
        !previousDescendants.find(
          (descendant) => descendant.element === element
        )
      ) {
        const updatedDescendats = [
          ...previousDescendants,
          { element, index: previousDescendants.length },
        ];

        return updatedDescendats.map((descendants, index) => {
          return {
            ...descendants,
            index,
          };
        });
      }

      return previousDescendants;
    });
  }, []);

  const unregisterDescendant = useCallback((element: HTMLElement) => {
    setDescendants((previousDescendants) =>
      previousDescendants.filter((descendant) => descendant.element !== element)
    );
  }, []);

  return { descendants, registerDescendant, unregisterDescendant };
};

const useDescendant = (
  ref: RefObject<HTMLElement>,
  context: ReturnType<typeof useDescendants>
) => {
  const { descendants, registerDescendant, unregisterDescendant } = context;

  useLayoutEffect(() => {
    if (ref.current) {
      registerDescendant(ref.current);
      return () => unregisterDescendant(ref.current);
    }
  }, []);

  const index = descendants.findIndex(
    (descendant) => descendant.element === ref.current
  );
  return index;
};

const DescendantContext = createContext<ReturnType<
  typeof useDescendants
> | null>(null);

const useDescendantsContext = () => {
  const ctxt = useContext(DescendantContext);
  if (!ctxt) {
    throw new Error("");
  }

  return ctxt;
};

const getInitialOpenPanelState = (
  multiple: boolean,
  collapsible: boolean,
  defaultIndex?: AccordionIndex
) => {
  if (defaultIndex != null) {
    if (multiple) {
      return Array.isArray(defaultIndex) ? defaultIndex : [defaultIndex];
    } else {
      return Array.isArray(defaultIndex) ? defaultIndex[0] ?? 0 : defaultIndex!;
    }
  } else {
    if (collapsible) {
      return multiple ? [] : -1;
    }
    return multiple ? [0] : 0;
  }
};

type AccordionIndex = number | number[];

type AccordionCtxt = {
  openPanels: AccordionIndex;
  onSelectPanel: (index: number) => void;
};
const AccordionContext = createContext<AccordionCtxt | undefined>(undefined);

const useAccordionContext = () => {
  const ctxt = useContext(AccordionContext);
  if (!ctxt) {
    throw new Error("");
  }

  return ctxt;
};

type AccordionProps = {
  //   A default value for the open panel's index or indices in an uncontrolled accordion component when it is initially rendered.
  defaultIndex?: AccordionIndex;
  //   Whether or not all panels of an uncontrolled accordion can be toggled to a closed state.
  collapsible?: boolean;
  //   Whether or not multiple panels in an uncontrolled accordion can be opened at the same time.
  multiple?: boolean;
};

const Accordion = ({
  children,
  defaultIndex,
  collapsible = false,
  multiple = false,
}: PropsWithChildren<AccordionProps>) => {
  const initialOpenPanelsVal = getInitialOpenPanelState(
    multiple,
    collapsible,
    defaultIndex
  );
  const [openPanels, setOpenPanels] =
    useState<ReturnType<typeof getInitialOpenPanelState>>(initialOpenPanelsVal);

  const onSelectPanel = useCallback(
    (index: number) => {
      setOpenPanels((prevOpenPanels) => {
        if (multiple) {
          prevOpenPanels = prevOpenPanels as number[];
          if (prevOpenPanels.includes(index)) {
            if (prevOpenPanels.length > 1 || collapsible) {
              return prevOpenPanels.filter((i) => i !== index);
            }
          } else {
            return [...prevOpenPanels, index].sort();
          }
        } else {
          prevOpenPanels = prevOpenPanels as number;
          return prevOpenPanels === index && collapsible ? -1 : index;
        }
        return prevOpenPanels;
      });
    },
    [collapsible, multiple]
  );

  const descsendantContext = useDescendants();

  return (
    <DescendantContext.Provider value={descsendantContext}>
      <AccordionContext.Provider value={{ openPanels, onSelectPanel }}>
        <div>{children}</div>
      </AccordionContext.Provider>
    </DescendantContext.Provider>
  );
};

enum AccordionStates {
  Open = "OPEN",
  Collapsed = "COLLAPSED",
}

type AccordionItemContext = {
  index: number;
  state: AccordionStates;
};

const AccordionItemContextValue = createContext<
  AccordionItemContext | undefined
>(undefined);

const useAccordionItemContext = () => {
  const ctxt = useContext(AccordionItemContextValue);
  if (!ctxt) {
    throw new Error();
  }

  return ctxt;
};

type AccordionItemProps = {};
const AccordionItem = ({ children }: PropsWithChildren<AccordionItemProps>) => {
  const ref = useRef<HTMLDivElement>(null);
  const ctxt = useDescendantsContext();
  const index = useDescendant(ref as RefObject<HTMLDivElement>, ctxt);
  const { openPanels } = useAccordionContext();
  const state =
    (Array.isArray(openPanels)
      ? openPanels.includes(index) && AccordionStates.Open
      : openPanels === index && AccordionStates.Open) ||
    AccordionStates.Collapsed;
  return (
    <AccordionItemContextValue.Provider value={{ index, state }}>
      <div ref={ref} className="accordion-item accordion-item-style">
        {children}
      </div>
    </AccordionItemContextValue.Provider>
  );
};

type AccordionButtonProps = {};
const AccordionButton = ({
  children,
}: PropsWithChildren<AccordionButtonProps>) => {
  const { onSelectPanel } = useAccordionContext();
  const { index } = useAccordionItemContext();

  return (
    <div onClick={() => onSelectPanel(index)} className="accordion-button">
      {children}
    </div>
  );
};

type AccordionPanelProps = {};
const AccordionPanel = ({
  children,
}: PropsWithChildren<AccordionPanelProps>) => {
  const { state } = useAccordionItemContext();
  return (
    <div hidden={state !== AccordionStates.Open} className="accordian-panel">
      {children}
    </div>
  );
};

export { Accordion, AccordionItem, AccordionButton, AccordionPanel };

Benefits

  • Separation of Concerns and Encapsulation

    • Encapsulated Logic: The Accordion component encapsulates the shared state and logic (like which panel is open) and passes this context to its subcomponents (AccordionItem, AccordionButton, AccordionPanel). This means each subcomponent only focuses on its own rendering and behavior.

    • Clear Responsibilities: Each component (e.g., the button or the panel) has a single responsibility. This separation makes the code easier to understand, maintain, and extend.

  • Flexible Composition and Reusability

    • Composable Building Blocks: Consumers of your Accordion component can compose and nest the subcomponents as needed. They can customize layout or behavior by rearranging these building blocks without worrying about breaking the internal state management.

    • Reusability: Since the components are decoupled, they can be reused in different contexts or even extracted to be used in other similar patterns.

  • State Sharing Without Prop Drilling

    • Context API: Using React’s Context API to share state (like which panels are open and how to toggle them) eliminates the need to pass props through every level of the component tree. This makes the component tree cleaner and the code easier to manage.

    • Centralized State Management: All the accordion’s behavior is managed in one place, reducing the risk of inconsistent states between components.

  • Customization and Extensibility

    • Flexible APIs: Consumers can extend or override parts of the Accordion without needing to modify the core logic. For example, if someone wants to use a different styling or add additional behaviors to the button, they can create a new component that still hooks into the Accordion’s context.

    • Theming and Styling: Since the components are separate, you can more easily apply different styles or themes to each part of the accordion.

  • Improved Testing and Debugging

    • Isolated Testing: Each compound component can be tested in isolation, allowing for more straightforward unit tests. For example, you can test the behavior of an AccordionButton separately from the overall Accordion state management.

    • Clear Data Flow: The use of context and clearly defined component boundaries simplifies the debugging process since it’s easier to trace how state and events flow through the component hierarchy.

Found this blog helpful? Give it a like and share it with your fellow developers to spread the knowledge!