React component patterns
Patterns are a way to organize code to make them easy to compose and reuse. Understanding patterns will make you a better programmer and speed up development as you have more tools in your toolkit to address problems.
Using patterns
There are a wide variety of React component patterns. As always, using a pattern requires understanding the tradeoffs between flexibility and reusability. If we expect a component to not change frequently, making it more configurable could be a mistake as it makes the code harder to read. Similarly, not making it configurable enough results in messy, and sometimes, duplicated code.
Since the introduction of React hooks, component patterns have become even more varied. We will be going through these few main patterns:
- Higher-ordered components
- Custom hooks
- Named children props
- Render props
- Context components
Higher-ordered components
Higher-ordered components (HOCs) is one of the earliest patterns, and it is even documented in React docs. HOCs are components that take in a component as an argument, and returns an enhanced version of the component.
Let's build a simple "scroll to top" HOC:
function withScrollToTop(WrappedComponent) {
function WithScrollToTop(props) {
useEffect(() => {
document.documentElement.scrollTop = 0;
}, []);
return <WrappedComponent {...props} />;
}
WithScrollToTop.displayName = `WithScrollToTop(${getDisplayName(
WrappedComponent,
)})`;
return WithScrollToTop;
}
// helper function to get the display name
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
// Example usage:
export default withScrollToTop(App);
Here we are utilizing React hooks, but you can also replace WithScrollToTop
with a class component.
HOCs are great at sharing common props and effects. Since the pattern has been around for a long time, the abstraction is easily understood by other users.
In practice, HOCs are not commonly used as writing types for they can be quite a hassle. Furthermore, all their use cases can now be replaced entirely with custom hooks, as we will see in the following section.
Custom hooks
React hooks allow us to write custom hooks by simply starting a function with the word use
. Let's rewrite our withScrollToTop
HOC into a custom hook:
function useScrollToTop() {
useEffect(() => {
document.documentElement.scrollTop = 0;
}, []);
}
// Example usage:
function App() {
useScrollToTop();
}
As you can see, custom hooks are easy to write and even easier to use. If you are in a code base where there are many class components, you can wrap the hook and provide a HOC variant:
function withScrollToTop(WrappedComponent) {
function WithScrollToTop(props) {
useScrollToTop();
return <WrappedComponent {...props} />;
}
WithScrollToTop.displayName = `WithScrollToTop(${getDisplayName(
WrappedComponent,
)})`;
return WithScrollToTop;
}
The downside of hooks is they do not work well with other patterns because of hook limitations such as hooks having to be declared at the top-level. As such, interactions with other patterns likely require you to create additional components to use hooks.
Named children prop
Although the children
prop is well understood that it allow users to inject any element into the component, it may be ambiguous at times. We can make the usage of the component clearer using named props, as such:
function TextInput({ startIcon, endIcon }) {
return (
<div>
{startIcon}
<input type="text" />
{endIcon}
</div>
);
}
// Example usage:
function App() {
return <TextInput startIcon={<SearchIcon />} endIcon={null} />;
}
This makes both reading and using the component much easier, and allows further customization in the future. One such modification we can make is to change the props to become render props.
Render props
While the named children prop pattern accepts elements as props, render props take in functions that return elements instead.
function TextInput({ renderStartIcon, renderEndIcon }) {
const [isEmpty, setIsEmpty] = useState(true);
return (
<div>
{renderStartIcon({ isEmpty })}
<input type="text" onChange={(e) => setIsEmpty(!e.target.value)} />
{renderStartIcon({ isEmpty })}
</div>
);
}
// Example usage:
function App() {
return (
<TextInput
renderStartIcon={({ isEmpty }) => (
<SearchIcon style={{ color: isEmpty ? 'gray' : 'black' }} />
)}
renderEndIcon={() => null}
/>
);
}
Using the render prop pattern, we have changed the TextInput component to pass the isEmpty
variable to our render functions. This pattern is more powerful as it let users dictate how they want the elements to be styled and function.
The unfortunate downside of the render prop function is its performance. As we are accepting functions instead of React elements, it bypasses the React diffing algorithm. Any time that TextInput re-renders, we are re-creating the render prop element unless both the render function and result of the render function are memoized.
function TextInput({ renderStartIcon, renderEndIcon }) {
const [isEmpty, setIsEmpty] = useState(true);
const startIcon = useMemo(() => {
return renderStartIcon({ isEmpty });
}, [renderStartIcon, isEmpty]);
return (
<div>
{renderStartIcon({ isEmpty })}
<input type="text" onChange={(e) => setIsEmpty(!e.target.value)} />
{renderStartIcon({ isEmpty })}
</div>
);
}
// Example usage:
function App() {
const renderStartIcon = useCallback(
({ isEmpty }) => (
<SearchIcon style={{ color: isEmpty ? 'gray' : 'black' }} />
),
[],
);
return (
<TextInput renderStartIcon={renderStartIcon} renderEndIcon={() => null} />
);
}
Although this approach is quite convoluted, it is necessary if the render prop functions are expensive and re-render frequently.
Context Components
Context components is an odd pattern. Instead of passing down a component through many different layers, we can utilize contexts to our advantage. Combined with render props, this becomes a great way to allow customization in a library.
const ModalContext = React.createContext(null);
function Modal(modalProps) {
const { renderModal } = useContext(ModalContext);
const [isShown, setIsShown] = useState(false);
const onBack = () => setIsShown(false);
return isShown ? renderModal(onBack) : null;
}
// Example usage:
function App() {
return (
<ModalContext.Provider
renderModal={(onBack) => <MyOwnModal onBack={onBack} />}
/>
);
}
Using context through this way also has performance impacts. If ModalContext's value changes, it will result in all Modal
components re-rendering. As such, the proper way is to wrap the component in yet more wrappers.
const ModalContext = React.createContext(null);
function Modal(modalProps) {
const { renderModal } = useContext(ModalContext);
const [isShown, setIsShown] = useState(false);
const onBack = useCallback(() => setIsShown(false), []);
return isShown ? renderModal(onBack) : null;
}
// Example usage:
function App() {
const renderModal = useCallback(
(onBack) => <MyOwnModal onBack={onBack} />,
[],
);
return <ModalContext.Provider renderModal={renderModal} />;
}
As such, due to the verbosity and uncommon usage, the context component pattern is quite rare in practice.
Conclusion
These are the few main patterns you can use when writing your next React component. Each pattern has its benefit and downsides, and, as such, requires good judgment for use. One way of practicing is simply to write more reusable components. With practice, you will get a better sense of when a pattern is more applicable and user-friendly, and write better software as a result!