⚛️ 🚀 React Component Patterns
Alexi Taylor 🐶
Posted on August 26, 2020
Overview
This documentation will help identify the trade-offs of the different React patterns and when each pattern would be most appropriate. The following patterns will allow for more useful and reusable code by adhering to design principles like separation of concern, DRY, and code reuse. Some of these patterns will help with problems that arise in large React applications such as prop drilling or managing state. Each major pattern includes an example hosted on CodeSandBox.
💡 The following examples are not complex so as not to confuse the reader with implementation details that do not pertain to the concepts of each component pattern.
📚 Table of Contents
⬆️ Compound Components
Overview
Compound components is a pattern where components are used together such that they share an implicit state that lets them communicate with each other in the background. A compound component is composed of a subset of child components that all work in tandem to produce some functionality.
Think of compound components like the
<select>
and<option>
elements in HTML. Apart they don’t do too much, but together they allow you to create the complete experience. — Kent C. Dodds
❓ Why use compound components? What value do they provide?
As a creator of a re-usable component, you should keep the consumer of the component in mind: other engineers that will use your component. This pattern provides flexibility for the consumers of the components. It allows you to abstract the internal workings of your components; the logic behind your reusable component that shouldn't concern the user. It provides a user-friendly interface where the consumer of the component is only concerned about the placement of the combined elements while providing a holistic experience.
Example
Let's dive into an example and create a radio image form. We will be creating a radio group form, but instead of showing the regular radio button inputs, we will be rendering a list of images that the user can select from. You can follow along with the final result in the CodeSandBox.
We will be creating one parent component, RadioImageForm
, which will be responsible for the form's logic and one child, "sub-component," RadioInput
, which will render the image radio inputs. Together they will create a single compound component.
{/* The parent component that handles the onChange events
and managing the state of the currently selected value. */}
<RadioImageForm>
{/* The child, sub-components.
Each sub-component is an radio input displayed as an image
where the user is able to click an image to select a value. */}
<RadioImageForm.RadioInput />
<RadioImageForm.RadioInput />
<RadioImageForm.RadioInput />
</RadioImageForm>
In the src/components/RadioImageForm.tsx
file we have 1 main component:
-
RadioImageForm
- First we create the parent component that will manage the state and handle the on change events of the form. The consumer of the component, other engineers using the component, can subscribe to the currently selected value of the radio inputs by passing a callback function prop,onStateChange
. With each form change, the component will handle updating the radio inputs and provide the current value to the consumer.
Within the RadioImageForm
component we have one static component or sub-component:
-
RadioInput
- Next, we will create a static component, a subset component of theRadioImageForm
component. TheRadioInput
is a static component that is accessible through the dot-syntax notation, e.g.<RadioImageForm.RadioInput/>
. This allows the consumer of our component to readily access our sub-components and provide them with control of how theRadioInput
is rendered within the form.
💡 The
RadioInput
component is a static property of theRadioImageForm
class. A compound component is composed of a parent component, theRadioImageForm
, and of static components, theRadioInput
. From here on in, I will refer to the static components as "sub-components."
Let's take the first steps to creating our RadioImageForm
component.
export class RadioImageForm extends React.Component<Props, State> {
static RadioInput = ({
currentValue,
onChange,
label,
value,
name,
imgSrc,
key,
}: RadioInputProps): React.ReactElement => (
//...
);
onChange = (): void => {
// ...
};
state = {
currentValue: '',
onChange: this.onChange,
defaultValue: this.props.defaultValue || '',
};
render(): React.ReactElement {
return (
<RadioImageFormWrapper>
<form>
{/* .... */}
</form>
</RadioImageFormWrapper>
)
}
}
When creating reusable components, we want to provide a component where the consumer has control over where elements are rendered in their code. But the RadioInput
components will need access to the internal state, the internal onChange
function, as well the user's props, for the experience to work properly. But how do we pass this data to the sub-components? This is where React.Children.map
and React.cloneElement
comes into play. For an in-depth explanation of how the two work you can dive into the React docs:
The end result of the RadioImageForm
render method looks like the following:
render(): React.ReactElement {
const { currentValue, onChange, defaultValue } = this.state;
return (
<RadioImageFormWrapper>
<form>
{
React.Children.map(this.props.children,
(child: React.ReactElement) =>
React.cloneElement(child, {
currentValue,
onChange,
defaultValue,
}),
)
}
</form>
</RadioImageFormWrapper>
)
}
Of note in this implementation:
-
RadioImageFormWrapper
- Our component styles with styled-components. We can ignore this as the CSS styles does not pertain to component pattern. -
React.Children.map
- It iterates through the component's direct children, allowing us to manipulate each direct child. -
React.cloneElement
- From the React docs:
Clone and return a new React element using an element as the starting point. The resulting element will have the original element’s props with the new props merged in shallowly. New children will replace existing children.
With React.Children.map
and React.cloneElement
we are able to iterate and manipulate each child. So we are able to pass additional props that we explicitly define in this transformation process. In this case, we can pass the RadioImageForm
internal state to each RadioInput
child component. Since React.cloneElement
performs a shallow merge, any props defined by the user on RadioInput
will be passed to the component.
Finally, we can declare the RadioInput
static property component on our RadioImageForm
class. This allows the consumer to call our subset component, RadioInput
, directly from RadioImageForm
using the dot-syntax notation. This helps improve readability and explicitly declares the sub-components. Through this interface, we have created a re-usable and user-friendly component. Here is our RadioInput
static component:
static RadioInput = ({
currentValue,
onChange,
label,
value,
name,
imgSrc,
key,
}: RadioInputProps) => (
<label className="radio-button-group" key={key}>
<input
type="radio"
name={name}
value={value}
aria-label={label}
onChange={onChange}
checked={currentValue === value}
aria-checked={currentValue === value}
/>
<img alt="" src={imgSrc} />
<div className="overlay">
{/* .... */}
</div>
</label>
);
💡 One thing to note is that we explicitly defined the model contract in
RadioInputProps
of what props the user can pass to theRadioInput
sub-components.
Then the consumer of the component can reference RadioInput
with the dot-syntax notation in their code (RadioImageForm.RadioInput
):
// src/index.tsx
<RadioImageForm onStateChange={onChange}>
{DATA.map(
({ label, value, imgSrc }): React.ReactElement => (
<RadioImageForm.RadioInput
label={label}
value={value}
name={label}
imgSrc={imgSrc}
key={imgSrc}
/>
),
)}
</RadioImageForm>
🚧 Since the
RadioInput
is a static property, it does not have access to theRadioImageForm
instance. Hence you can not directly reference the state or methods defined inRadioImageForm
class. e.g.this.onChange
will not work in the following example:static RadioInput = () => <input onChange={this.onChange} //...
Conclusion
With this flexible philosophy, we have abstracted the implementation details of the radio image form. As simple as the internal logic of our component may be, with more complex components we are able to abstract the internal workings from the user. The parent component, RadioImageForm
, deals with the on change event actions and updating the currently checked radio input. And the RadioInput
sub-component is able to determine the currently selected input. We have provided the basic styling for the radio image form. An added bonus is we have also included accessibility to our components. This internal logic of the RadioImageForm
component of managing the state of the form, applying the currently checked radio input, and applying the form styles are implementation details that should not concern engineers using our component.
Drawbacks
While we have created user-friendly interface for users of our components, there is a hole within our design. What if the <RadioImageForm.RadioInput/>
is buried in a bunch of divs? What happens if the consumer of the component wants to re-arrange the layout? The component will still render, but the radio input will not receive the current value from RadioImageForm
state, hence breaking our user experience. This component pattern is not flexible, which brings us to our next component pattern.
⬆️ Compound Components CodeSandBox
🚀 Example of Compound Components with functional components and React hooks:
⬆️ Compound Components w/ functional components CodeSandBox
⬆️ Flexible Compound Components
Overview
In our previous example, we utilized the compound component pattern, but what happens when we wrap our sub-component in a bunch of divs? It breaks. It's not flexible. The problem with compound components is that it can only clone and pass props to immediate children.
❓ Why use flexible compound components? What value do they provide?
With Flexible Compound Components, we can implicitly access the internal state of our class component regardless of where they're rendered within the component tree. Another reason to use Flexible Compound Components is when several components need to share state, regardless of their position in the component tree. The consumer of the component should have the flexibility of where to render our compound components. In order to accomplish this, we will use the React's Context API.
💡 But first we should gain some context 😉 about React's Context API by reading through the official React docs.
Example
We will continue with our radio image form example and refactor RadioImageForm
component to use the flexible compound component pattern. You can follow along with the final result in the CodeSandBox.
Let's create some context for our RadioImageForm
component so we can pass data to the child components (e.g. RadioInput
) anywhere within the parent's component tree. Hopefully, you have brushed up on React's Context, but here's a concise summary from React's doc:
Context provides a way to pass data through the component tree without having to pass props down manually at every level.
First, we call React.createContext
method, providing default values to our context. Next, we will assign a display name to the context object. We will add this to the top of our RadioImageForm.tsx
file.
const RadioImageFormContext = React.createContext({
currentValue: '',
defaultValue: undefined,
onChange: () => { },
});
RadioImageFormContext.displayName = 'RadioImageForm';
- By calling
React.createContext
we have created a context object containing aProvider
andConsumer
pair. The former will provide data to the latter; in our example, theProvider
will expose our internal state to the sub-components. - By assigning a
displayName
to our context object, we can easily differentiate between context components in React Dev Tool. So instead of havingContext.Provider
orContext.Consumer
we will haveRadioImageForm.Provider
andRadioImageForm.Consumer
. This helps readability if we have multiple components using Context while debugging.
Next we can refactor the RadioImageForm
component's render function and remove the drab React.Children.map
and React.cloneElement
functions and render the children prop.
render(): React.ReactElement {
const { children } = this.props;
return (
<RadioImageFormWrapper>
<RadioImageFormContext.Provider value={this.state}>
{children}
</RadioImageFormContext.Provider>
</RadioImageFormWrapper>
);
}
The RadioImageFormContext.Provider
accepts one prop named value
. The data passed to the value
prop is the context that we want to provide to the descendants of this Provider. The sub-components need access to our internal state, as well as the internal onChange
function. By assigning the onChange
method, currentValue
, and defaultValue
to the state
object we can then pass this.state
to the context value.
🚧 Whenever this
value
changes to something else it will re-render itself and all of its consumers. React is constantly rendering, so by passing an object to thevalue
prop it will re-render all of the child components, because the object is being reallocated on each render (creating a new object on each render). This could inevitably cause performance problems, because the passed in object to thevalue
prop will be re-created every time a child component re-renders even if the values in the object haven't changed. DON'T DO THIS:<RadioImageFormContext.Provider value={{ currentValue: this.state.currentValue, onChange: this.onChange }}>
. Instead, passthis.state
to prevent any child components from unnecessary re-rendering.
And finally, our sub-components can consume the provided context, our internal data, that we just created earlier. Since our sub-components are all internal in our RadioImageForm
component, we can define the Consumer
as a static property of RadioImageForm
.
export class RadioImageForm extends React.Component<Props, State> {
static Consumer = RadioImageFormContext.Consumer;
//...
💡 Alternatively, if you have external components that need to subscribe to the context, you can export the
RadioImageFormContext.Consumer
within the file, e.g.export const RadioImageFormConsumer = RadioImageFormContext.Consumer
.
For each of our sub-components, we can declare the Consumer
using the dot-syntax notation by rendering the consumer as the root element.
For example purposes, we will create a submit button where the user can provide a callback function where we will be able to pass the currentValue
provided from our context value. In our RadioImageForm
we will create the SubmitButton
component.
static SubmitButton = ({ onSubmit }: SubmitButtonProps) => (
<RadioImageForm.Consumer>
{({ currentValue }) => (
<button
type="button"
className="btn btn-primary"
onClick={() => onSubmit(currentValue)}
disabled={!currentValue}
aria-disabled={!currentValue}
>
Submit
</button>
)}
</RadioImageForm.Consumer>
);
One thing to note is that the Consumer
requires a function as a child; it uses the render props pattern. e.g. ({ currentValue }) => (// Render content))
. This function receives the current context value, subscribing to the internal state changes. This allows us to explicitly declare what data we need from the Provider
. For instance, the SubmitButton
expects the currentValue
property, which was a reference on the RadioImageForm
class. But now it receives direct access to those values via the Context.
💡 For a better understanding of how the render prop works (the function as a child concept), you can visit the React Docs.
With these changes, the user of our component is able to use our compound components anywhere in the component tree. In the src/index.tsx
file, you can view how a consumer of our component could use it.
Conclusion
With this pattern, we are able to design components that are reusable with the flexibility for the consumer of our component to use in diverse contexts. We have provided a component-friendly interface where the consumer of the component does not need knowledge of the internal logic. With the Context API, we can pass the implicit state of our component to the sub-components regardless of their depth in the hierarchy. This gives control to the user to enhance the stylistic aspect of the components. And that's the beauty of Flexible Compound Components: they help with separating presentation from the internal logic. Implementing compound components with the Context API is more advantageous, and why I would recommend starting with Flexible Compound Component over Compound Component pattern.
⬆️ Flexible Compound Component CodeSandBox
🚀 Example of Flexible Compound Components with functional components and React hooks:
⬆️ Flexible Compound Components w/ Functional Components CodeSandBox
⬆️ Provider Pattern
Overview
The provider pattern is an elegant solution to share data across the React component tree. The provider pattern utilizes the previous concepts we have learned, the two major ones being React's context API and render props.
💡 For more insight visit the React docs on Context API and Render Props.
Context API:
Context provides a way to pass data through the component tree without having to pass props down manually at every level.
Render Props:
The term “render prop” refers to a technique for sharing code between React components using a prop whose value is a function.
❓ Why use provider pattern? What value do they provide?
The provider pattern is a powerful concept that helps when designing a complex application since it solves several problems. With React, we have to deal with uni-directional data flow, and when combining several components we have to prop drill shared state from parent level to child descendant components. This can lead to unsightly spaghetti code.
A challenge of loading and displaying shared data on a page is providing that shared state to the child components that need access to it. By utilizing React's Context API we can create a data provider component that deals with fetching data and providing the shared state to the entire component tree. This way, multiple child components, regardless of how deeply nested, can access the same data. Fetching data and showing data are two separate concerns. Ideally, a single component has a single responsibility. The parent, data wrapper (the provider) component's primary concern is data fetching and handling the shared state while the child components can focus on how to render that data. The provider component can also handle the business logic of normalizing and data massaging the response data, so that the child components consistently receive the same model even when API endpoints are updated and the response data model changes. This separation of concerns is valuable when building big apps, as it helps with maintainability and simplifying the development. Other developers are able to easily determine the responsibility of each component.
Some may question, why not use a state management library like Redux, MobX, Recoil, Rematch, Unstated, Easy Peasy, or the handful of others? While these libraries can help with one's state management problem, there is no need to over-engineer the problem. Introducing a state management library creates a lot of repetitive boilerplate code, complex flows that other developers need to learn, and app bloat that increases the app footprint. Now, I am not telling you that a state management library is useless and that you shouldn't use one, but rather that it is important to be aware of what value it provides and justify the usage of importing a new library. When I initialized my app with React I opted out of using a state management library, even though it seemed every other React project was doing so. While my requirements to do so may be different from others, I saw no reason to complicate our codebase with a state management tool that future developers may have to learn. Rather I went with the solution of using the provider pattern.
Example
After that long-winded introduction, let's dive into an example. This time we will be creating a very simple app to demonstrate how we can easily share state between components, and even pages, all while adhering to design principles like separation of concerns and DRY. You can follow along with the final result in the CodeSandBox. In our example, we will create a dog social app where our user can view their profile and a list of their dog friends.
First, let's create the data provider component, DogDataProvider
, that will be responsible for fetching our data and providing it to the child components, regardless of their position in the component tree, by utilizing React's Context API.
// src/components/DogDataProvider.tsx
interface State {
data: IDog;
status: Status;
error: Error;
}
const initState: State = { status: Status.loading, data: null, error: null };
const DogDataProviderContext = React.createContext(undefined);
DogDataProviderContext.displayName = 'DogDataProvider';
const DogDataProvider: React.FC = ({ children }): React.ReactElement => {
const [state, setState] = React.useState<State>(initState);
React.useEffect(() => {
setState(initState);
(async (): Promise<void> => {
try {
// MOCK API CALL
const asyncMockApiFn = async (): Promise<IDog> =>
await new Promise(resolve => setTimeout(() => resolve(DATA), 1000));
const data = await asyncMockApiFn();
setState({
data,
status: Status.loaded,
error: null
});
} catch (error) {
setState({
error,
status: Status.error,
data: null
});
}
})();
}, []);
return (
<DogDataProviderContext.Provider value={state}>
{children}
</DogDataProviderContext.Provider>
);
};
Of note in this implementation:
- First off, we create a context object,
DogDataProviderContext
, with React's Context API viaReact.createContext
. This will be used to provide state to consuming components with a custom React hook that we will implement later. - By assigning a
displayName
to our context object, we can easily differentiate between context components in React Dev Tool. So instead of havingContext.Provider
we will haveDogDataProvider.Provider
in our React Dev Tools. This helps readability if we have multiple components using Context while debugging. - In our
useEffect
hook we will fetch and manage the same shared data that will be consumed by multiple child components. - The model of our state includes our creatively named data property, status property, and error property. With these three properties, the child components can decide what states to render: 1. a loading state, 2. a loaded state with the rendered data, or 3. an error state.
- Since we have de-coupled the loading and managing of data from the UI components that are concerned about displaying it, we won't have unnecessary data fetching when the UI components are mounted and un-mounted.
Next, we will create our custom React hook in the same file that we created the DogDataProvider
component. The custom hook will provide the context state from the DogDataProvider
component to the consuming components.
// src/components/DogDataProvider.tsx
export function useDogProviderState() {
const context = React.useContext(DogDataProviderContext);
if (context === undefined) {
throw new Error('useDogProviderState must be used within DogDataProvider.');
}
return context;
}
The custom hook uses [React.useContext](https://reactjs.org/docs/hooks-reference.html#usecontext)
to get the provided context value from the DogDataProvider
component, and it will return the context state when we call it. By exposing the custom hook, the consumer components can subscribe to the state that is managed in the provider data component.
Also, we have added error handling if the hook is called in a component that is not a descendant of the data provider component. This will ensure if misused that it will fail fast and provide a valuable error message.
Finally, we display the data when loaded in the consuming components. We will focus on the Profile
component that is loaded in the home path, but you can also see examples of the consumer components in DogFriends
and Nav
components.
First, in the index.tsx
file we have to wrap the DogDataProvider
component at the root level:
// src/index.tsx
function App() {
return (
<Router>
<div className="App">
{/* The data provder component responsible
for fetching and managing the data for the child components.
This needs to be at the top level of our component tree.*/}
<DogDataProvider>
<Nav />
<main className="py-5 md:py-20 max-w-screen-xl mx-auto text-center text-white w-full">
<Banner
title={'React Component Patterns:'}
subtitle={'Provider Pattern'}
/>
<Switch>
<Route exact path="/">
{/* A child component that will consume the data from
the data provider component, DogDataProvider. */}
<Profile />
</Route>
<Route path="/friends">
{/* A child component that will consume the data from
the data provider component, DogDataProvider. */}
<DogFriends />
</Route>
</Switch>
</main>
</DogDataProvider>
</div>
</Router>
);
}
Then in the Profile
component we can use the custom hook, useDogProviderState
:
const Profile = () => {
// Our custom hook that "subscirbes" to the state changes in
// the data provider component, DogDataProvider.
const { data, status, error } = useDogProviderState();
return (
<div>
<h1 className="//...">Profile</h1>
<div className="mt-10">
{/* If the API call returns an error we will show an error message */}
{error ? (
<Error errorMessage={error.message} />
// Show a loading state when we are fetching the data
) : status === Status.loading ? (
<Loader isInherit={true} />
) : (
// Display the content with the data
// provided via the custom hook, useDogProviderState.
<ProfileCard data={data} />
)}
</div>
</div>
);
};
Of note in this implementation:
- When fetching the data, we will show a loading state.
- If the API call returns an error, we will show an error message.
- Finally, once the data is fetched and provided via the custom hook,
useDogProviderState
, we will render theProfileCard
component.
Conclusion
This is a contrived example that's intentionally simplified to demonstrate the powerful concept of the provider pattern. But we have created an elegant basis of how data fetching, managing state, and displaying that data can be accomplished in a React application.
⬆️ Provider Pattern with Custom Example
💡 Since React hooks were introduced in React v16.8, if you need to support versions less than v16.8, here is the same example without hooks: CodeSandBox.
Happy Coding 🚀
If you liked this content, follow me on Twitter @alexi_be3 💙
Updates:
09/02/2020: Thank you, Dmitry, for pointing out that for the Provider Pattern you need to pass undefined
as the default value to React.useContext()
; otherwise, the custom consumer hook, useDogProviderState
, will never throw an error. I have updated the example with this change. Also, thank you for providing an example of Flexible Compound Components example with functional components. I have added CodeSandBox examples for Compound Components and Flexible Compound Components with functional components.
Posted on August 26, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 8, 2024