DEV Community

Cover image for Building highly reusable React.js components using compound pattern
Junior Garcia
Junior Garcia

Posted on

Building highly reusable React.js components using compound pattern

Today I bring you a way to create a highly reusable React component using an advance pattern called Compound.

Compound Components pattern

The keyword in the pattern’s name is the word Compound, the word compound refers to something that is composed of two or more separate elements.

With respect to React components, this could mean a component that is composed of two or more separate components. The main component is usually called the parent, and the separate composed components, children.

Look at the following example:

codeimg-twitter-instream-image.png

Here, <Select> is the parent component and the <Select.Option> are children components

The overall behaviour of a select element also relies on having these composed option elements as well. Hence, they are connected to one another.

The state of the entire component is managed by Select component with all Select.Option child components dependent on that state.

Do you get a sense of what compound components are now?

Compound components are just one of many ways to express the API for your components.

We are going to build the Select component we saw above which will be composed of 2 additional components Select Dropdown and Select Option.

component-mockup.png
In the code block above, you’ll notice I have used expressions like this: Select.Option

You can do this as well:

codeimg-twitter-instream-image (2).png

Both work but it is a matter of personal preference. In my opinion, it communicates the dependency of the main component well, but that is just my preference.

Feel free to use whatever component looks best to you!

Building the compound child components

The Select is our main component, will keep track of the state, and it will do this via a boolean variable called visible.

// select state 
{
  visible: true || false
}
Enter fullscreen mode Exit fullscreen mode

The Select component needs to communicate the state to every child component regardless of their position in the nested component tree.

Remember that the children are dependent on the parent compound component for the state.

What would be the best way to do it?

We need to use the React Context API to hold the component state and expose the visible property via the Provider component. Alongside the visible property, we will also expose a string prop to hold the selected option value.

We’ll be creating this in a file called select-context.js

import { createContext, useContext } from 'react'

const defaultContext = {
  visible: false,
  value: ''
};

export const SelectContext = createContext(defaultContext);

export const useSelectContext = () => useContext(SelectContext);
Enter fullscreen mode Exit fullscreen mode

Now we have to create a file called select-dropdown.js which is the container for the select options.

Note: I use Styled Components for the styles, feel free to use whatever styling way looks best to you!

import React from "react";
import PropTypes from "prop-types";
import { StyledDropdown } from "./styles";

const SelectDropdown = ({ visible, children, className = "" }) => {
  return (
    <StyledDropdown visible={visible} className={className}>
      {children}
    </StyledDropdown>
  );
};

SelectDropdown.propTypes = {
  children: PropTypes.oneOfType([
    PropTypes.arrayOf(PropTypes.node),
    PropTypes.node
  ]),
  visible: PropTypes.bool.isRequired,
  className: PropTypes.string
};

export default SelectDropdown;
Enter fullscreen mode Exit fullscreen mode

Next, we need to create a file called styles.js to save component styles.

import styled, { css } from "styled-components";

export const StyledDropdown = styled.div`
  position: absolute;
  border-radius: 1.375rem;
  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12);
  background-color: #fff;
  max-height: 15rem;
  width: 80vw;
  overflow-y: auto;
  overflow-anchor: none;
  padding: 1rem 0;
  opacity: ${(props) => (props.visible ? 1 : 0)};
  visibility: ${(props) => (props.visible ? "visible" : "hidden")};
  top: 70px;
  left: 10px;
  z-index: 1100;
  transition: opacity 0.2s, transform 0.2s, bottom 0.2s ease,
    -webkit-transform 0.2s;
`;
Enter fullscreen mode Exit fullscreen mode

Note that with the visible property we control the visibility of the dropdown

Then we need to create the children component, for this, we create a file called select-option.js.

import React, { useMemo } from "react";
import { useSelectContext } from "./select-context";
import { StyledOption } from "./styles";
import PropTypes from "prop-types";


const SelectOption = ({
  children,
  value: identValue,
  className = "",
  disabled = false
}) => {
  const { updateValue, value, disableAll } = useSelectContext();

  const isDisabled = useMemo(() => disabled || disableAll, [
    disabled,
    disableAll
  ]);

  const selected = useMemo(() => {
    if (!value) return false;
    if (typeof value === "string") {
      return identValue === value;
    }
  }, [identValue, value]);

  const bgColor = useMemo(() => {
    if (isDisabled) return "#f0eef1";
    return selected ? "#3378F7" : "#fff";
  }, [selected, isDisabled]);

  const hoverBgColor = useMemo(() => {
    if (isDisabled || selected) return bgColor;
    return "#f0eef1";
  }, [selected, isDisabled, bgColor]);

  const color = useMemo(() => {
    if (isDisabled) return "#888888";
    return selected ? "#fff" : "#888888";
  }, [selected, isDisabled]);

  const handleClick = (event) => {
    event.preventDefault();
    if (typeof updateValue === "function" && identValue !== value) {
      updateValue(identValue);
    }
  };

  return (
    <StyledOption
      className={className}
      bgColor={bgColor}
      hoverBgColor={hoverBgColor}
      color={color}
      idDisabled={disabled}
      disabled={disabled}
      onClick={handleClick}
    >
      {children}
    </StyledOption>
  );
};

SelectOption.propTypes = {
  children: PropTypes.oneOfType([
    PropTypes.arrayOf(PropTypes.node),
    PropTypes.node
  ]),
  value: PropTypes.string,
  className: PropTypes.string,
  disabled: PropTypes.boolean
};

export default SelectOption;
Enter fullscreen mode Exit fullscreen mode

I know it's confused, but I’ll break it down.

First, let's focus on the following line of code:

 const { updateValue, value, disableAll } = useSelectContext();
Enter fullscreen mode Exit fullscreen mode

We use useSelectContext() from select-context.js to access the context data, "⚠️Spoiler alert": we are going to manage this data on our main component, Yes you are correct is the Select component.

The value prop from context is the selected value.

Also, we use useMemo on several occasions to prevent unnecessary renders.

  const bgColor = useMemo(() => {
    if (isDisabled) return "#f0eef1";
    return selected ? "#3378F7" : "#fff";
  }, [selected, isDisabled]);

Enter fullscreen mode Exit fullscreen mode

useMemo takes a callback that returns the string value with hexadecimal colour code and we pass an array dependency [selected, isDisabled]. This means that the memoized value remains the same unless the dependencies change.

Note: If you have a theme you can use the HOC (High Order Component) component called withTheme and use your colours

Not sure how useMemo works? Have a look at this cheatsheet.

Now to finalize the SelectOption component we need to create the StyledOption component for that we go to the styles.js file and write the following code:

export const StyledOption = styled.div`
  display: flex;
  max-width: 100%;
  justify-content: flex-start;
  align-items: center;
  font-weight: normal;
  font-size: 1.3rem;
  height: 4rem;
  padding: 0 2rem;
  background-color: ${(props) => props.bgColor};
  color: ${(props) => props.color};
  user-select: none;
  border: 0;
  cursor: ${(props) => (props.isDisabled ? "not-allowed" : "pointer")};
  transition: background 0.2s ease 0s, border-color 0.2s ease 0s;
  &:hover {
    background-color: ${(props) => props.hoverBgColor};
  }
`;
Enter fullscreen mode Exit fullscreen mode

Creating the main component

Up to this point, we have all the child components of our main component, now we are going to create the main component Select, for that we need to create a file called select.js with the following code:

import React, { useState, useCallback, useMemo, useEffect } from "react";
import { SelectContext } from "./select-context";
import { StyledSelect, StyledValue, StyledIcon, TruncatedText } from "./styles";
import SelectDropdown from "./select-dropdown";
import { pickChildByProps } from "../../utils";
import { ChevronDown } from "react-iconly";
import PropTypes from "prop-types";

const Select = ({
  children,
  value: customValue,
  disabled = false,
  onChange,
  icon: Icon = ChevronDown,
  className,
  placeholder = "Choose one"
}) => {
  const [visible, setVisible] = useState(false);
  const [value, setValue] = useState(undefined);

  useEffect(() => {
    if (customValue === undefined) return;
    setValue(customValue);
  }, [customValue]);

  const updateVisible = useCallback((next) => {
    setVisible(next);
  }, []);

  const updateValue = useCallback(
    (next) => {
      setValue(next);
      if (typeof onChange === "function") {
        onChange(next);
      }
      setVisible(false);
    },
    [onChange]
  );

  const clickHandler = (event) => {
    event.preventDefault();
    if (disabled) return;
    setVisible(!visible);
  };

  const initialValue = useMemo(
    () => ({
      value,
      visible,
      updateValue,
      updateVisible,
      disableAll: disabled
    }),
    [visible, updateVisible, updateValue, disabled, value]
  );

  const selectedChild = useMemo(() => {
    const [, optionChildren] = pickChildByProps(children, "value", value);
    return React.Children.map(optionChildren, (child) => {
      if (!React.isValidElement(child)) return null;
      const el = React.cloneElement(child, { preventAllEvents: true });
      return el;
    });
  }, [value, children]);

  return (
    <SelectContext.Provider value={initialValue}>
      <StyledSelect
        disabled={disabled}
        className={className}
        onClick={clickHandler}
      >
        <StyledValue isPlaceholder={!value}>
          <TruncatedText height="4rem">
            {!value ? placeholder : selectedChild}
          </TruncatedText>
        </StyledValue>
        <StyledIcon visible={visible}>
          <Icon />
        </StyledIcon>
        <SelectDropdown visible={visible}>{children}</SelectDropdown>
      </StyledSelect>
    </SelectContext.Provider>
  );
};

Select.propTypes = {
  children: PropTypes.oneOfType([
    PropTypes.arrayOf(PropTypes.node),
    PropTypes.node
  ]),
  disabled: PropTypes.bool,
  icon: PropTypes.element,
  value: PropTypes.string,
  placeholder: PropTypes.string,
  onChange: PropTypes.func,
  className: PropTypes.string
};

export default Select;

Enter fullscreen mode Exit fullscreen mode

I will start by explaining the propTypes:

  • children: Are the array of Select.Option
  • disabled: Is used to set the disabled state in Select and Select.Option
  • value: Is the default selected value
  • placeholder: Is used to show a text if there aren't any Select.Option selected.
  • onChange: Callback to communicate when the value has changed
  • className: Class name for Select component

Perfect now let's focus on the useState React hook, it's used to manage selected value status and dropdown menu visibility

  const [visible, setVisible] = useState(false);
  const [value, setValue] = useState(undefined);
Enter fullscreen mode Exit fullscreen mode

To set the initial value of Select (in case one is set), we need to use the hook useEffect

  useEffect(() => {
    if (customValue === undefined) return;
    setValue(customValue);
  }, [customValue]);

Enter fullscreen mode Exit fullscreen mode

Not sure how useEffect works? Have a look at this cheatsheet.

  const updateVisible = useCallback((next) => {
    setVisible(next);
  }, []);

  const updateValue = useCallback(
    (next) => {
      setValue(next);
      if (typeof onChange === "function") {
        onChange(next);
      }
      setVisible(false);
    },
    [onChange]
  );
Enter fullscreen mode Exit fullscreen mode

Another hooks we are using is useCallback, this hook will return a memoized version of the callback that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders (e.g. shouldComponentUpdate).

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Not sure how useCallback works? Have a look at this [cheatsheet]https://react-hooks-cheatsheet.com/usecallback).

Now we are going to focus on context initial value, let's see following code:

  const initialValue = useMemo(
    () => ({
      value,
      visible,
      updateValue,
      updateVisible,
      disableAll: disabled
    }),
    [visible, updateVisible, updateValue, disabled, value]
  );

return (
    <SelectContext.Provider value={initialValue}>
     // ---- ///
    </SelectContext.Provider>
  );
Enter fullscreen mode Exit fullscreen mode

In the above code, we use the useMemo to prevent unnecessary re-renders passing in the array the props that can change, then we pass that initial value to theSelectContect.Provider, we have been using each of these properties in the components we saw earlier.

Last but not least, we have a function to get selected option component, let's see following code:

export const pickChildByProps = (children, key, value) => {
  const target = [];
  const withoutPropChildren = React.Children.map(children, (item) => {
    if (!React.isValidElement(item)) return null;
    if (!item.props) return item;
    if (item.props[key] === value) {
      target.push(item);
      return null;
    }
    return item;
  });

  const targetChildren = target.length >= 0 ? target : undefined;

  return [withoutPropChildren, targetChildren];
};

 const selectedChild = useMemo(() => {
    const [, optionChildren] = pickChildByProps(children, "value", value);
    return React.Children.map(optionChildren, (child) => {
      if (!React.isValidElement(child)) return null;
      const el = React.cloneElement(child, { preventAllEvents: true });
      return el;
    });
  }, [value, children]);
Enter fullscreen mode Exit fullscreen mode

In a few words, what we do is clone the selected option and put it in the header of the Select component.

Now we need to create the necessary styles for the Select component:

export const StyledSelect = styled.div`
  position: relative;
  z-index: 100;
  display: inline-flex;
  align-items: center;
  user-select: none;
  white-space: nowrap;
  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
  width: 80vw;
  transition: border 0.2s ease 0s, color 0.2s ease-out 0s,
    box-shadow 0.2s ease 0s;
  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12);
  border: 2px solid #f5f5f5;
  border-radius: 3rem;
  height: 4rem;
  padding: 0 1rem 0 1rem;
  background-color: ${(props) => (props.disabled ? "#f0eef1" : "#fff")};
  &:hover {
    border-color: ${(props) => (props.disabled ? "#888888" : "#3378F7")};
  }
`;

export const StyledIcon = styled.div`
  position: absolute;
  right: 2rem;
  font-size: ${(props) => props.size};
  top: 50%;
  bottom: 0;
  transform: translateY(-50%)
    rotate(${(props) => (props.visible ? "180" : "0")}deg);
  pointer-events: none;
  transition: transform 200ms ease;
  display: flex;
  align-items: center;
  color: #999999;
`;

export const StyledValue = styled.div`
  display: inline-flex;
  flex: 1;
  height: 100%;
  align-items: center;
  line-height: 1;
  padding: 0;
  margin-right: 1.25rem;
  font-size: 1.3rem;
  color: "#888888";
  width: calc(100% - 1.25rem);
  ${StyledOption} {
    border-radius: 0;
    background-color: transparent;
    padding: 0;
    margin: 0;
    color: inherit;
    &:hover {
      border-radius: inherit;
      background-color: inherit;
      padding: inherit;
      margin: inherit;
      color: inherit;
    }
  }
  ${({ isPlaceholder }) =>
    isPlaceholder &&
    css`
      color: #bcbabb;
    `}
`;
Enter fullscreen mode Exit fullscreen mode

Feel free to change the colours, you can use the theme object of styled-components to get the theme colours

Finally, we need to export our component 👏🏻


import Select from "./select";
import SelectOption from "./select-option";

// Remember this is just a personal preference. It's not mandatory
Select.Option = SelectOption;

export default Select;
Enter fullscreen mode Exit fullscreen mode

Congratulations! 🎊, now you have a reusable highly optimized component created, you can apply this pattern in many cases.

Final result

Here you can see the final result:

Top comments (2)

Collapse
 
ronca85 profile image
ronca85

i see many problems with this approach. here's just a few:

  1. your approach completely ignores accessibility principles. everything is a div, why?
  2. you wrote a lot of code to make a very simple element. why not use a native element?
  3. i don't see how this is reusable. i can use this only in react, right? the styles are also baked into the component so no way for me to use this outside of react.
Collapse
 
jrgarciadev profile image
Junior Garcia

Hi ronca85, I will answer you with pleasure:

  1. In order to save lines of code I was forced not to focus on the accessibility side, I leave you an example with which you can guide you to add the accessibility points
    github.com/Semantic-Org/Semantic-U..., github.com/Semantic-Org/Semantic-U.... I used div because is more comfortable to build a custom select component, you can see any "Select" component of famous react components libraries, in fact, this tutorial is base on "Select" component made by geist-ui github.com/geist-org/react/blob/ma... currently used by Vercel.

  2. Is not a simple component, I know you can use and HTML tag but is not common use it because is more complex to customize, you can see famous React libraries and they all use custom code to do it, for example, Material UIhttps://github.com/mui-org/material-ui/tree/next/packages/material-ui/src/Select

  3. Is totally reusable in React, you can't use this code in another framework/library without adapt because of it's based on the React API such as useMemo, useCallback, etc...

And remember the main goal of this tutorial is to learn about the Compound Pattern in React so that you can use it in any component.