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:
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
.
In the code block above, you’ll notice I have used expressions like this: Select.Option
You can do this as well:
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 calledwithTheme
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 ofSelect.Option
disabled
: Is used to set the disabled state inSelect
andSelect.Option
value
: Is the default selected valueplaceholder
: Is used to show a text if there aren't anySelect.Option
selected.onChange
: Callback to communicate when the value has changedclassName
: Class name forSelect
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 ofstyled-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: