Building highly reusable React.js components using compound pattern

Building highly reusable React.js components using compound pattern

Featured on Hashnode
Featured on daily.dev
Junior Garcia's photo
Junior Garcia

Published on Dec 23, 2020

17 min read

Subscribe to my newsletter and never miss my upcoming articles

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 two variables called visible (boolean) and value (string).

// select state 
{
  visible: true || false
  value: ''
}

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);

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;

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;
`;

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;

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();

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]);

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};
  }
`;

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;

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);

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]);

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]
  );

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]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>
  );

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]);

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;
    `}
`;

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;

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

Final result

Here yo can see the final result:

 
Share this
Proudly part of