DEV Community

Cover image for React Component Patterns
DhiWise
DhiWise

Posted on

React Component Patterns

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.

Compound Components

Overview

Compound components are 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 and 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, RadioImageFormwhich 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>
Enter fullscreen mode Exit fullscreen mode

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 the component we have one static component or sub-component:
RadioInput - Next, we will create a static component, a subset component of the RadioImageForm component. The RadioInput is a static component that is accessible through the dot-syntax notation, e.g. <RadioImageForm.RadioInput/>. This allows the consumer of our components to readily access our sub-components and provide them with control of how they RadioInput are rendered within the form.

💡 The RadioInput component is a static property of the RadioImageForm class. A compound component is composed of a parent component, the RadioImageForm, and of static components, the RadioInput. From here on in, I will refer to the static components as "sub-components."

Let's take the first steps to create 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>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

When creating reusable components, we want to provide a component where the consumer controls elements 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:
React.Children.map
React.cloneElement

The 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Of note in this implementation:
RadioImageFormWrapper - Our component styles with styled-components. We can ignore this as the CSS styles do not pertain to the 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 can iterate and manipulate each child. So we can 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 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 reusable 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>
);
Enter fullscreen mode Exit fullscreen mode

💡 One thing to note is that we explicitly defined the model contract in RadioInputProps of what props the user can pass to the RadioInput 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>
Enter fullscreen mode Exit fullscreen mode

🚧 Since the RadioInput is a static property, it does not have access to the RadioImageForm instance. Hence you can not directly reference the state or methods defined in RadioImageForm 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 maybe, with more complex components we can abstract the internal workings from the user. The parent component RadioImageFormdeals with the on-change event actions and updating the currently checked radio input. And the RadioInput sub-component can determine the currently selected input. We have provided the basic styling for the radio image form. A bonus is we have also included access 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 a user-friendly interface for users of our components, there is a hole within our design. What if the 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 the 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 they 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 a 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. To accomplish this, we will use 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';
Enter fullscreen mode Exit fullscreen mode

By calling React.createContext we have created a context object containing a Provider and Consumer pair. The former will provide data to the latter; in our example, the Provider 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 having Context.Provider or Context.Consumer we will have RadioImageForm.Provider and RadioImageForm.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>
  );
}
Enter fullscreen mode Exit fullscreen mode

The RadioImageFormContext.Provider accepts one prop named value. The data passed to the value the 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 it 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 the value 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 the value 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, pass this.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;
  //...

Enter fullscreen mode Exit fullscreen mode

💡 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 sub-component, we can declare 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>
);
Enter fullscreen mode Exit fullscreen mode

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 can 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 can 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 patterns? 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 development. Other developers can 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 a 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 states 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Of note in this implementation:

1.First off, we create a context object, DogDataProviderContextwith React's Context API via React.createContext. This will be used to provide state to consuming components with a custom React hook that we will implement later.
2.By assigning a displayName to our context object, we can easily differentiate between context components in React Dev Tool. So instead of having Context.Provider we will have DogDataProvider.Provider in our React Dev Tools. This helps readability if we have multiple components using Context while debugging.
3.In our useEffect the hook we will fetch and manage the same shared data that will be consumed by multiple child components.
4.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.
5.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;
}
Enter fullscreen mode Exit fullscreen mode

The custom hook uses React.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 the 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 the file we have to wrap the DogDataProvider the 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then in the Profile the 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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, we will render the component once the data is fetched and provided via the custom hook.

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.
Source:
https://dev.to/alexi_be3/react-component-patterns-49ho

Discussion (0)