20-Dec

React

Subscription pattern with Compound components in React

Compound component is a react design pattern popular in UI libraries such as Chakra, Reach, Semantic and Material. It allows us to write a declarative and flexible API for complex components that implicitly share a state. Today we'll look at an introduction and a slightly more complex example of the patthern

6 min read

·

By Jørund Amsen

·

December 20, 2021

I first discovered this pattern when attempting to create a customizable and reusable accordion component. Up until then, I'd usually see them implemented with an api similar to this:

<Accordion header="My header text"> 
  <div> My content <div/>
</Accoridion>

With this component I was faced with two problems: I needed an icon in my header, not just text and I needed to style my header differently than its base styling. For the latter, I could always inspect the component and style the class directly, but I feel that's breaking into the encapsulation of the api. This is a common occurence when designing reusable react components: one component with multiple parts each with its own demand for customizability. What I did need was a pattern where each part of a component was exposed for customization. Something like this:

<Accordion>
  <Header className="my-custom-class">
    My header with an icon: <Icon/> 
  </Header>
  <Panel>
    My accordion content
  </Panel>
</Accordion>

To achieve this my components had to share state and communicate. In particular, clicking the header had to open and close the panel. This is already functionally similar to a basic html component:


<select name="Best christmas drink" id="drink">
  <option value="glogg">Gløgg</option>
  <option value="akevitt">Akevitt</option>
  <option value="julebrus">Julebrus</option>
</select>

Which also has an implicitly shared state between the components, clicking one will affect the others.

This can be achieved in numerous ways, the two most common being React.cloneElement and the Context api. The latter of which I'd recommended and is what we'll look at now:

const AccordionContext = React.createContext<
  | {
      isOpen: boolean;
      toggleIsOpen: () => void;
    }
  | undefined
>(undefined);

function Accordion({ children }: PropsWithChildren<{}>) {
  const [isOpen, setIsOpen] = useState(false);
  const toggleIsOpen = () => setIsOpen((prev) => !prev);

  return (
    <AccordionContext.Provider value={{ isOpen, toggleIsOpen }}>
      {children}
    </AccordionContext.Provider>
  );
}

function useAccordionContext() {
  const context = React.useContext(AccordionContext)
  if(!context) {
    throw new Error("Can't use Header and Panel outside Accordion, yo")
  }
  return context
}

function Header({children}: PropsWithChildren<{}>) {
  const {toggleIsOpen} = useAccordionContext()
  return <h2 onClick={toggleIsOpen}>{children}</h2>
}

function Panel({children} : PropsWithChildren<{}>) {
  const {isOpen} = useAccordionContext()
  return isOpen ? children : null
}

Let's go over it:

1. We define a shared context containing the state of the component and possible actions. This can be extended to share more state, like component id's for aria-properties.

2. We define a "provider" component, one that has to wrap all other components and supply the context. Usually this component contains most of the logic and handles the orchestration of state. In more complex components we could also define a reducer. It wraps all children in a Provider for the context. This means that on their own, Header and Panel don't function.

3. We create a context hook to simplify it's use and handle undefined context in case someone forgot to wrap their components in Accordion. This is a common and recommended practice. (Check out Kent C. Dodds' article if you'd like to read more)

4. We define our two compound components, each of which consume the context. The Header has an onClick flipping the state. And the Panel either shows or hides its content depending on the state.

We use it like so:

<Accordion>
  <Header>
    <div className="custom-styling" >Header content <MyIcon/></div>
  </Header>
  <Panel>
    Inside panel
  </Panel>
</Accordion>

However for a "proper" accordion we should support multiple accordions, and keep track of which ones are open and not. That can't be too hard right? Let's attempt to create a component with an API looking like this:

<AccordionGroup>
  <Accordion>
    <Header>
      <div>Header 1</div>
    </Header>
    <Panel>Panel 1</Panel>
  </Accordion>
  <Accordion>
    <Header>
      <div>Header 2</div>
    </Header>
    <Panel>Panel 2</Panel>
  </Accordion>
</AccordionGroup>

For the astute reader you may have noticed this is not a trivial extension. Our AccordionGroup (which is the new level of Provider component) needs to keep track of how many Accordions it has as children, wether they are open or not, and each child must have a way to know if it should be open or not and toggle itself. To solve this we'll attempt a subscription based design.

For this we need a hook for each Accordion, which will alert the AccordionGroup that it has rendered within the Context and should add it to the list of Accordions. This hook should also supply the Accordion with some way of knowing wether it should be open or not. This hook should alert the AccordionGroup if it un-renders from the page, so the Group can remove it. Lastly we need a list of all Accordions within the AccordionGroup, and logic to keep track of which Accordion is open.

We'll start by defining the state we'll need

type AccordionState = { id: string; isOpen: boolean };
const AccordionGroupContext = React.createContext<
  | {
      accordions: AccordionState[];
      toggleAccordion: (id: string) => void;
      subscribe: (id: string) => void;
      unSubscribe: (id: string) => void;
    }
  | undefined
>(undefined);

function useAccordionGroupContext() {
  const context = useContext(AccordionGroupContext);
  if (!context) {
    throw new Error("Gotsa puts the Accordions in the AccordionGroup");
  }
  return context;
}

Now we could have simply had an "accordion" and "setAccordion" to control all the state, but I prefer to abstract such logic away from subcomponents as much as possible and supply simple functions. In this case, accordions will be a list of accordions with id's and wether or not they are open. (I'm aware that a simpler implementation exist where you only keep a list of open accordions, but just roll with it aight? ). We also add the usual context hook.

What I've also added here are subscribe and unsubscribe functions that subcomponents can run when mounting and unmounting the DOM. These functions will add and remove accordions from the list so that at any point we will know exactly how many accordions there are as children, and their respective state.

Now that we've defined the context, we will, like last time, also add a Provider component, whos responsibility will be state logic and the context provider:

function AccordionGroup({ children }: PropsWithChildren<{}>) {
  const [accordions, setAccordions] = useState<AccordionState[]>([]);

  const subscribe = (id: string) =>
    setAccordions((prev) => [...prev, { id, isOpen: false }]);

  const unSubscribe = (id: string) =>
    setAccordions((prev) => prev.filter((acc) => acc.id !== id));

  const toggleAccordion = (id: string) =>
    setAccordions((prevAccs) => {
      const selected = prevAccs.find((acc) => acc.id === id);
      if (!selected) {
        return prevAccs;
      }
      const withoutSelected = prevAccs.filter((acc) => acc.id !== id);
      return [
        ...withoutSelected,
        { id: selected.id, isOpen: !selected.isOpen }
      ];
    });

  return (
    <AccordionGroupContext.Provider
      value={{ toggleAccordion, accordions, subscribe, unSubscribe }}
    >
      {children}
    </AccordionGroupContext.Provider>
  );
}

A bit more happening here. This is where we abstract handling of the state away from subcomponents, and only supply helper-functions with minimal interface.

Now finally, where most of the magic happens. We need a hook to handle the subscription and unsubscription of each child. As we will also see, it will abstract away even more of the state logic to clean up the Accordion components.

function useAccordionGroup() {
  const {
    accordions,
    toggleAccordion,
    subscribe,
    unSubscribe
  } = useAccordionGroupContext();
  const [id] = useState(randomString());
  const toggleIsOpen = () => toggleAccordion(id);
  const isOpen = accordions.find((acc) => acc.id === id)?.isOpen ?? false;

  useEffect(() => {
    subscribe(id);
    return () => {
      unSubscribe(id);
    };
  }, []);

  return { isOpen, toggleIsOpen };
}

const randomString = () => Math.random().toString(36).substring(2, 15)

In this hook we leverage the useEffect hook with an empty dependency array to only run the hook on render. We also return a lambda from useEffect, which is ran when the component unmounts, also called a clean-up function. Doing this a component using this hook can add itself to the list upon mounting, and remove itself upon unmounting. Also added to this hook is further abstraction away from the raw context state and id, so that all it returns to the component are functions only capable of the actions it needs.

Lastly, we replace the old state of the Accordion component with a call to the new hook:

function Accordion({ children }: PropsWithChildren<{}>) {
  const { isOpen, toggleIsOpen } = useAccordionGroup();

  return (
    <AccordionContext.Provider value={{ isOpen, toggleIsOpen }}>
      {children}
    </AccordionContext.Provider>
  );
}

Et voila! We have now looked at an introduction to compound components. At first we created a basic component based on a pattern you can reuse for a wide array of more complex use cases. We've seen how it exposes internal components and allows us to more directly customize them, rather than having them locked behind a high-level definition. We also looked at a more complex pattern. Where sharing the existence of separate components was important. We looked at how we could use useEffect to subscribe and unsubscribe components, how to leverage hooks to abstract complex state logic and how to allow for communication across components.

Compound components is a very powerful tool, and something anyone wanting to create components made for reusability and future-proofing should have in the toolbox.

Here is the full code