DEV Community

Robin Philip Thomas
Robin Philip Thomas

Posted on • Originally published at blog.robie.dev on

Level up your React component architecture

With newer versions of React focusing more on functional components, hooks and context, it is time that we re-look some of the component patterns in react.

Components are the heart of React, so understanding how to utilize them is paramount to creating an effective design structure. In this article, we will be looking at some popular design patterns for building React components.

1. Render props pattern

This is one of the simplest component patterns. Here you pass components as props to your parent component and the parent component takes care of rendering it in the right place.

A render prop is a prop on a component, whos value is a function that returns a JSX element. The component simply calls the render prop at the right place within the template.

Let's look at a sample Modal component. We want your Modal component to display a custom Title and Footer based on the page they are called. The render props pattern is what we will use here.

Usage

<Modal title={<CustomModalTitle />} footer={<CustomModalFooter />}>

Enter fullscreen mode Exit fullscreen mode

CustomModalTitle and CustomModalFooter are components specific to the page that the user passed to the Modal component. Modal component accepts them as props and renders them appropriately.

Implementation

export const Modal = ({ title, footer, children }) => {
  return (
    <div className="modal">
      <div className="modal-title">{title}</div>
      <div className="modal-content">{children}</div>
      <div className="modal-footer">{footer}</div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

Lifting state up

Through the render prop pattern, we can also pass internal data from the parent Modal component to the incoming render prop elements.

export const Modal = ({ title, footer, children }) => {
 const data = { ... }

  return (
    <div className="modal">
      <div className="modal-title">{title}</div>
      <div className="modal-content">{children}</div>
      <div className="modal-footer">{footer(data)}</div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

The render prop can now receive this value like this:

<Modal
        ...
  footer={(data) => <CustomModalFooter data={data} />}
>

Enter fullscreen mode Exit fullscreen mode

Children as a render prop

Besides regular JSX components, we can pass functions as children to React components. This function is available to us through the children prop, which is technically also a render prop.

Let's update our example so that we also pass our internal data value to children as well.

export const Modal = ({ title, footer, children }) => {
 const data = { ... }

  return (
    <div className="modal">
      <div className="modal-title">{title}</div>
      <div className="modal-content">{children(data)}</div>
      <div className="modal-footer">{footer(data)}</div>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

Calling the Modal component can be changed to this:

<Modal ... >
  {(data) =>(
      <div>
        Hello, {data.name}
      </div>
    )
  }
</Modal>

Enter fullscreen mode Exit fullscreen mode

Here's this component in action:

https://codesandbox.io/embed/render-props-pattern-rjfti1?fontsize=12&hidenavigation=1&theme=dark

Pros

  • Simple to implement and understand.
  • Sharing logic and data among several components is easy with the render props pattern. Components can be made very reusable, by using a render or children prop.

Cons

  • No control over what users pass as render prop.
  • Quite difficult to achieve tight logic sharing between render prop elements and parent component.

2. Compound component pattern

The compound components are a set of two or more components that work together to accomplish a specific task. The set of components will share an implicit state to communicate between them.

Think of compound components like the <select> and <option> elements in HTML. Apart they dont do too much, but together they allow you to create a complete experience.Kent C. Dodds

<select>
 <option>Option1</option>
 <option>Option2</option>
 <option>Option3</option>
 <option>Option4</option>
</select>

Enter fullscreen mode Exit fullscreen mode

Alright, let's take a look at a legit React component that exposes a compound component to understand these principles further. Here's an example of the <CheckboxGroup /> component that exposes a compound components API:

 <CheckboxGroup
  title="hello form"
  checkedItems={checkedItems}
  onChange={onCheckBoxChange}
>
    <CheckboxGroup.Item label="One" name="one" />
    <CheckboxGroup.Item label="Two" name="two" />
</CheckboxGroup>

Enter fullscreen mode Exit fullscreen mode

In this example, the <CheckboxGroup> establishes some shared implicit state. The <CheckboxGroup.Item> components each access and/or manipulate that state, and it's all done implicitly. This allows you to have the expressive API you're looking for.

checkedItems and onChange are passed to the CheckboxGroup and they are then internally passed to all the innerCheckboxGroup.Items.

Let's look at the implementation

export const CheckboxGroup = ({
  title,
  checkedItems,
  onChange,
  children
}) => {
  const providerState = useMemo(
    () => ({
      checkedItems,
      onChange: onChange
    }),
    [checkedItems, onChange]
  );

  return (
    <div >
      <form>
        {
          // So here we can take all children and make a copy of them that has those props.
          Children.toArray(children).map((child) => {
            if (isValidElement(child)) {
              return cloneElement(child, {
                ...child.props,
                ...providerState
              });
            }
            return null;
          })
        }
      </form>
    </div>
  );
};

export const CheckboxItem = ({
  name,
  label,
  checkedItems,
  onChange
}) => {
  return (
    <label className="container">
      {label}
      <input
        type="checkbox"
        name={name}
        checked={checkedItems!.includes(name)}
        onChange={onChange}
      />
      <span className="checkmark"></span>
    </label>
  );
};

CheckboxGroup.Item = CheckboxItem;

Enter fullscreen mode Exit fullscreen mode

When creating reusable components, we want to provide a component where the consumer has control over where or how many elements are rendered in their code.

The <CheckboxItem 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.toArray and React.cloneElement comes into play.

  • CheckboxGroup - Parent component to which we pass our state and onChange events.

  • React.Children.toArray(children).map - It iterates through the component's direct children, allowing us to manipulate each direct child.

  • React.cloneElement - Clone and return a new React element with some extra props.

With React.Children.toArray(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 CheckboxGroup internal state to each CheckboxItem child component. Since React.cloneElement performs a shallow merge, any props defined by the user on CheckboxItem will be passed to the component.

Finally, we can declare the CheckboxItem static property component on our CheckboxGroup component. This allows the consumer to call our subset component, CheckboxItem, directly from CheckboxGroup using the dot-syntax notation. This helps improve readability and explicitly declares the sub-components.

Here's this component in action:

https://codesandbox.io/embed/compound-component-pattern-0hz6qw?autoresize=1&fontsize=12&hidenavigation=1&theme=dark

Pros

  • Expressive component API
  • Implicit props sharing between parent and child components
  • Users have more control over the templating.

Cons

  • Does not work when there are multiple nested elements. (Since we only close the direct children)
  • Can break if the user decides to add other elements to children.

3. Context compound component pattern

Context compound is a more flexible version of the compound component pattern we saw before. The problem with compound components is that they can only clone and pass props to immediate children.

With Context compound component, we can implicitly access the internal state of our component regardless of where they're rendered within the component tree. Another reason to use Context compound components is when several components need to share states, regardless of their position in the component tree.

The consumer of the component should be free to choose where to render our compound components. In order to accomplish this, we will use React's Context API.

How is this more flexible you ask? Let's look at an example. We'll build a counter component with + and - buttons to increment/decrement the count.

image.png

The component API would look as such

<Counter onChange={handleChangeCounter}>
  <Counter.Decrement />
  <Counter.Label>Counter</Counter.Label>
  <Counter.Count />
  <Counter.Increment />
</Counter>

Enter fullscreen mode Exit fullscreen mode

Flexible Markup Structure: Your component has great UI flexibility, allowing the creation of various cases from a single component. For example, the user can change the SubComponents order or define which one should be displayed.

image.png

Let's look at the implementation

import { Decrement, Increment, Label, Count } from "./components";

export const Counter = ({
  children,
  onChange,
  initialValue = 0
}) => {
  const [count, setCount] = useState(initialValue);

  const handleIncrement = useCallback(() => {
    setCount(count + 1);
    onChange(count + 1);
  }, [count, onChange]);

  const handleDecrement = useCallback(() => {
    setCount(Math.max(0, count - 1));
    onChange(Math.max(0, count - 1));
  }, [count, onChange]);

  const value = useMemo(() => {
    return {
      count,
      handleIncrement,
      handleDecrement
    };
  }, [count, handleIncrement, handleDecrement]);

  return (
    <CounterProvider value={value}>
      <div className="counter-wrapper">{children}</div>
    </CounterProvider>
  );
};

Counter.Decrement = Decrement;
Counter.Increment = Increment;
Counter.Label = Label;
Counter.Count = Count;

Enter fullscreen mode Exit fullscreen mode

We start by creating a context and wrapping the whole component inside a ContextProvider. We pass our shared state and function to the context so that all child components have access to it.

<CounterProvider value={value}>
  <div className="counter-wrapper">{children}</div>
</CounterProvider>

Enter fullscreen mode Exit fullscreen mode

Getting the state in the sub-components is as simple as reading from the context.

export const Count = () => {
  const { count } = useContext(CounterContext);;

  return <div className="counter-count">{count}</div>;
};

Enter fullscreen mode Exit fullscreen mode

Here's this component in action:

https://codesandbox.io/embed/context-component-pattern-58xr2r?autoresize=1&fontsize=14&hidenavigation=1&theme=dark

Pros

  • Reduced API Complexity: Instead of jamming all props in one giant parent component and drilling those down to child UI components, here each prop is attached to the SubComponent that makes the most sense.
  • Flexible Markup Structure: Your component has great UI flexibility, allowing the creation of various cases from a single component. For example, the user can change the SubComponents order or define which one should be displayed.
  • Separation of Concerns: Most of the logic is contained in the main Counter component, a React.Context is then used to share states and handlers across all children. We get a clear division of responsibility.

Cons

  • Too much UI flexibility: Having flexibility comes along with the possibility to provoke unexpected behaviour (putting an unwanted Components child, making out of order the Components children, forgetting to include a mandatory child).

4. Custom hook pattern

In this pattern, we use a custom hook along with the component template. All the logic needed for the component to work comes from this custom hook. This makes it possible for you to share logic without being opinionated about the UI.

Let's look at an example where we build a sample toggle component.

export default function App() {
  const { on, toggle, setOn, setOff } = useToggle();

  return (
    <div>

        <button onClick={setOn}>turn on</button>
        <button onClick={setOff}>turn on</button>

      <Toggle on={on} onClick={toggle} />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

From the useToggle hook we get the handlers and state for the toggle switch.useToggle hook can even be modified to pass props to it, to be used in the logic.

eg:

const { on, toggle, setOn, setOff } = useToggle({ initialValue: true });

Enter fullscreen mode Exit fullscreen mode

Let's look at the implementation

export const useToggle = () => {
  const [on, setOnState] = useState(false);

  const toggle = () => setOnState((o) => !o);
  const setOn = () => setOnState(true);
  const setOff = () => setOnState(false);

  return { on, toggle, setOn, setOff };
};

Enter fullscreen mode Exit fullscreen mode

Here's this component in action:

https://codesandbox.io/embed/modern-snowflake-30uj3r?autoresize=1&fontsize=14&hidenavigation=1&theme=dark

Pros

  • Give more control: The user can insert their own logic between the hook and the JSX element, allowing them to modify the default component behaviour.

Cons

  • Implementation complexity: Since the logic part is separated from the rendering part, it is the user who must link both. A good understanding of how your component works is required to correctly implement it.

5. State reducer pattern

This pattern is similar to the Custom hook pattern, but in addition, the user defines a reducer which is passed to the hook. This reducer will overload any internal action of your component.

Now, let's say we wanted to adjust the <Toggle /> component so the user couldn't click the Switch more than 4 times in a row unless they click a "Reset" button.

As a user, it'd be cool if I could hook into every state update before it actually happens and modify it. We can achieve this by passing a reducer to the hook. The reducer will have currentState and action as params. Then the action could have a type that determines what type of change is happening, and we could get the changes from the toggleReducer which would be exported by our useToggle hook. We'll just say that the type for clicking the switch is TOGGLE.

usage

export default function App() {
  const [clicksSinceReset, setClicksSinceReset] = useState(0);
  const tooManyClicks = clicksSinceReset >= 4;

  const { on, toggle, setOn, setOff } = useToggle({
    reducer(currentState, action) {
      const changes = toggleReducer(currentState, action);
      if (tooManyClicks && action.type === ActionTypes.toggle) {
        // other changes are fine, but on needs to be unchanged
        return { ...changes, on: currentState.on };
      } else {
        // the changes are fine
        return changes;
      }
    }
  });

  return (
      <div>
          <button onClick={setOff}>Switch Off</button>
          <button onClick={setOn}>Switch On</button>

        <Toggle
          onClick={() => {
            toggle();
            setClicksSinceReset((count) => count + 1);
          }}
          on={on}
        />
        {tooManyClicks ? (
          <button onClick={() => setClicksSinceReset(0)}>Reset</button>
        ) : null}
      </div>

Enter fullscreen mode Exit fullscreen mode

Let's take a look at how we could implement this with our useToggle hook.

We could add logic to every one of these helper functions. However, I'm just going to skip ahead and tell you that this would be really annoying, even in this simple hook. Instead, we're going to rewrite this from useState to useReducer and that'll make our implementation a LOT easier:

const toggleReducer = (state: ToggleState, action: ToggleAction) => {
  switch (action.type) {
    case ActionTypes.toggle: {
      return { on: !state.on };
    }
    case ActionTypes.on: {
      return { on: true };
    }
    case ActionTypes.off: {
      return { on: false };
    }
    default: {
      throw new Error(`Unhandled type: ${action.type}`);
    }
  }
};

export const useToggle = () => {
  const [{ on }, dispatch] = useReducer(toggleReducer, { on: false });

  const toggle = () => dispatch({ type: ActionTypes.toggle });
  const setOn = () => dispatch({ type: ActionTypes.on });
  const setOff = () => dispatch({ type: ActionTypes.off });

  return { on, toggle, setOn, setOff };
};

Enter fullscreen mode Exit fullscreen mode

Every handler fires a dispatch call to the reducer with the relevant ActionType.

We also want users to pass a custom reducer if they need to as a configuration object to our useToggle function, so let's accept that:

export const useToggle = ({ reducer = toggleReducer } = {}) => {
  const [{ on }, dispatch] = useReducer(reducer, { on: false });

  const toggle = () => dispatch({ type: ActionTypes.toggle });
  const setOn = () => dispatch({ type: ActionTypes.on });
  const setOff = () => dispatch({ type: ActionTypes.off });

  return { on, toggle, setOn, setOff };
};

Enter fullscreen mode Exit fullscreen mode

See how we give a default value to the reducer in useToggle. The default value will be used if the user does not want to modify the default behaviour.

Okay, so now that we have the developer's reducer, how do we combine that with our reducer? Well, if we're truly going to invert control for the user of our hook, we don't want to call our own reducer. Instead, let's expose our own reducer and they can use it themselves if they want to, so let's export it, and then we'll use the reducer they give us instead of our own:

export const toggleReducer = (state: ToggleState, action: ToggleAction) => ...

Enter fullscreen mode Exit fullscreen mode

Awesome, so now people can use our useToggle hook with their own reducer or they can use it with the built-in one. Either way works just as well.

Here's this component in action:

https://codesandbox.io/embed/state-reducer-pattern-vz9lyt?autoresize=1&fontsize=14&hidenavigation=1&theme=dark

Pros

  • Give more control: In the most complicated cases, using state reducers is the best way to leave control to the user. All your internal components actions are now accessible from the outside and can be overridden.

Cons:

  • Implementation complexity: This pattern is surely the most complex to implement, both for you and for the user.
  • Lack of visibility: Since any reducers action can be changed, a good understanding of the Components internal logic is required.

6. Props getter pattern

Custom hook pattern gives great control, but also makes your component harder to integrate. This is because the user has to deal with a lot of native hook props and recreate the logic on his/her side. The Props getters pattern attempts to mask this complexity. Instead of exposing native props, we provide a shortlist of props getters. A getter is a function that returns many props, it has a meaningful name allowing the user to naturally link it to the right JSX element.

Let's see how our Toggle example would look if we were using props getter pattern.

export default function App() {
  const { on, getTogglerProps } = useToggle();

  return (
    <div>

      Toggle state: {on ? "on" : "off"}
      <Toggle {...getTogglerProps()} />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

getTogglerProps() takes care of passing the right props to the <Toggle/> component.

Implementation would look something like this:

const callAll = (...fns: Function[]) => (...args: any) =>
  fns.forEach((fn) => fn && fn(...args));

export const useToggle = () => {
  const [on, setOnState] = useState(false);

  const toggle = () => setOnState((o) => !o);

  const getTogglerProps = (props: any = {}) => ({
    on: on,
    "aria-expanded": on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props?.onClick, toggle)
  });

  return { on, toggle, getTogglerProps };
};

Enter fullscreen mode Exit fullscreen mode

getTogglerProps function makes sure that our Toggle component receives the toggle state, onClick action and also has the correct aria-label and tabIndex values.

If we want to override the default behaviour, all we have to do is explicitly pass the props to override to the <Toggle /> component.

<Toggle {...getTogglerProps()} tabIndex={-1}/>

Enter fullscreen mode Exit fullscreen mode

Here's this component in action:

https://codesandbox.io/embed/props-getter-pattern-b75zm7?autoresize=1&fontsize=14&hidenavigation=1&theme=dark

Pros

  • Ease of use: Provide an easy way to integrate your component, the complexity is hidden, and the user just has to connect the correct getter to the right JSX element.

  • Flexibility: The user is able to overload the props within the getters to adapt to their specific needs.

Cons

  • Lack of visibility: The abstraction brought by the getters makes your component easier to integrate, but also more opaque and magic. To correctly override your component, the user has to know the list of props exposed by the getters and the internal logic impact if one of them is changed.

Conclusion

We looked at some of the core functional component patterns in React(that I know of). But in a real-world situation, you could build components that use multiple of these patterns together to make an extremely flexible API for situations where that's useful.

It is your role as a developer to choose the correct pattern corresponding to the right need. Whether to give the users of the component more control over the internal workings of the component or not.

This article was mainly inspired by the amazing work of Kent C. Dodds and Alexis Regnaud. Take a look at their blogs (Kent, Alex) if you are interested in knowing more about each of the patterns.


I hope you have found this useful. If you decide to try some of these patterns or need any help, send me a message on Twitter. I would love to help.

Top comments (0)