Different methods of composition in React

Published
2023-05-31
Tags
Design SystemsReactarchitectureadvanced

One of the features that makes components libraries built with React so powerful is its ability to compose smaller components into bigger ones. In this blog post, we will explore three different methods of composition in React: cloneElement, use of React context, and render functions. We will also compare the pros and cons of each method.

Using cloneElement

The cloneElement method is a built-in method in React that allows developers to clone and modify an existing React element. This method can be useful when we want to reuse an existing component with slight modifications. The cloned element will inherit all the properties of the original component, but we can also add new properties or override existing ones.

This method might seem as a good way of providing your component with some additional props but it might lead to huge problems. Consider the following example:

import React, { cloneElement } from 'react';

const Button = ({ children, color }) => {
  return (
    <button style={{ backgroundColor: color }}>
      {children}
    </button>
  );
};

// This button injects some click handler 
const WithOnClick = (props) => cloneElement(
	props.children, 
	{ onClick: (event) => console.log('clicked', event.target} // This seems fine in code but there is a huge problem there...
)

const App = () => {
  return (
    <WithOnClick>
      <Button onClick={() => console.log('This will never be called 😱')}>Click me</Button>
    </WithOnClick>
  );
};

As you can see, in this example, the click handler from the WithOnClick component will implicitly overwrite the onClick function of the children. This can and will lead to some nasty bugs so you have to be very careful using cloneElement in your code.

On the other hand, if that’s what you want that can come quite handy. For example, one use case could be a ButtonGroup component: in the button group we want all buttons to be the same size. We can ensure this without requiring the user to pass the size prop to each of the buttons.

import React from 'react';

const Button = ({ children, size }) => {
  return (
    <button style={{ fontSize: size }}>
      {children}
    </button>
  );
};

const ButtonGroup = ({ children, size }) => {
  const buttons = React.Children.toArray(children);
  return (
    <div>
      {buttons.map((button, index) => (
        React.cloneElement(button, { size: size, key: index })
      ))}
    </div>
  );
};

const App = () => {
  return (
    <ButtonGroup size="large">
      <Button>First button</Button>
      <Button size="small">Second button</Button>
      <Button>Third button</Button>
    </ButtonGroup>
  );
};

In this example, we use cloneElement to guard against improper use and restrict the use of size prop on an individual Button inside the ButtonGroup.

⚠️
I do not recommend to use cloneElement though, because it is very implicit and completely takes control from the user of the library. Also, it is assuming a very specific component tree (in our example, we has to use React.Children.toArray) which is also very brittle.

Use of Context

React’s Context is a way to pass data through the component tree without having to pass props down manually at every level. This method can be useful when we have data that needs to be accessed by many components at different levels of the tree.

Let’s rewire the ButtonGroup example using the context:

import React, { createContext, useContext } from 'react';

const Button = ({ children, size: ownSize }) => {
  const { size: contextSize } = useContext(ButtonGroupContext);
	const size = ownSize ?? contextSize // Own size prop takes precendence but you can change the rules here if you want to restrict the use.
  return (
    <button style={{ fontSize: size }}>
      {children}
    </button>
  );
};

const ButtonGroupContext = createContext({ size: 'medium' });

const ButtonGroup = ({ children, size }) => {
  return (
    <ButtonGroupContext.Provider value={{ size }}>
      <div>
        {children}
      </div>
    </ButtonGroupContext.Provider>
  );
};

const App = () => {
  return (
    <ButtonGroup size="large">
      <Button>First button</Button>
      <Button>Second button</Button>
      <Button>Third button</Button>
    </ButtonGroup>
  );
};

In this example, we use createContext to create a context object and useContext to consume it in the Button component. The ButtonGroup component wraps its children with a ButtonGroupContext.Provider component, which passes the size prop to its children.

The difference compared to the cloneElement method is that:

  1. User still has control in case it’s needed
  1. It works even with a more complex element tree

There are still problems, though:

  1. Button component need to implement the use of context which makes the Button less isolated
  1. It’s still quite implicit so it requires a very good documentation and users need to be educated about it.
💡
React Context is a good way of sharing data between compound components without exposing it to the end user.

Render Functions

A render function is a function that returns a React element. This method can be useful when we want to create a component that can render different content based on the props it receives. A good example could be a FormControl that handles label rendering for a form control. Consider this initial implementation:

import React from 'react';

const FormControl = ({ label, children }) => {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '1em' }}>
      <label>{label}</label>
      {children}
    </div>
  );
};

const App = () => {
  return (
    <div>
      <FormControl label="Username">
        <input type="text" />
      </FormControl>
      <FormControl label="Password">
        <input type="password" />
      </FormControl>
    </div>
  );
};

In this example, we define a FormControl component that takes a label prop and some children. The FormControl component renders a label and the children. We then use the FormControl component to create two form controls: one for the username and one for the password.

This looks okay but is not accessible since the label and the input aren’t bound together. In order to fix this, we need to generate or pass id to both label (using htmlFor attribute) and children. The context would not work, since the input isn’t reading from it. This would limit the FormControl to only work with specific components which isn’t what we want.

We can improve this example by using a render function instead of just children

import React from 'react';

const FormControl = ({ label, id, children }) => {
	const fieldId = label?.toLowerCase() ?? id
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '1em' }}>
      {label && (
        <label htmlFor={fieldId}>
          {label}
        </label>
      )}
      {children({ id: fieldId })}
    </div>
  );
};

const App = () => {
  return (
    <div>
      <FormControl label="Username">
        {({ id }) => <input type="text" id={id} />}
      </FormControl>
      <FormControl label="Password">
        {(props) => <input type="password" {...props} />}
      </FormControl>
    </div>
  );
};

In this example, the children prop becomes a function that takes an object with an id property. Later, we can use it to set the id attribute on our input element. Or, we can spread the arguments into the input element to avoid picking them manually.

With this method, it’s important to ensure type safety, so I recommend using TypeScript for this but this is outside of the scope of this post.

As you can see, this is much more explicit than the use of cloneElement and it gives the user total control over the props still.

This technique is also great when some of your components have slightly different interfaces (let’s say during the migration phase) since you can re-assign arguments to the right props.

💡
By using children as a function pattern, we can compose components in a more flexible way while still keeping them isolated. This pattern allows us to create components that accept arbitrary content while still providing a simple and consistent API.

Custom Hooks

Similar to render function, we can use custom hooks to share common data between components. This method can be useful when we want to hide some shared data and spread it on multiple compound components.

import React from 'react';

const useFormControl = (label) => {
  const id = label.toLowerCase().replace(' ', '-') ?? ''; // Or any other method of generating unique identifiers

	// Return an object with all the data
  return {
    id,
    label,
  };
};

const FormControl = ({ children, label, id }) => {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '1em' }}>
      {label && (
        <label htmlFor={id}>
          {label}
        </label>
      )}
      {children}
    </div>
  );
};

const App = () => {
	const usernameProps = useFormControl('Username');
	const passwordProps = useFormControl('Password');
  return (
    <div>
      <FormControl {...usernameProps}>
        <input type="text" {usernameProps.id} />
      </FormControl>
      <FormControl {...passwordProps}>
        <input type="password" id={passwordProps.id} />
      </FormControl>
    </div>
  );
};

In this example, we use a custom hook called useFormControl to generate the id and label props for the FormControl. The useFormControl hook takes a label and returns an object that contains the id and label. Now, we can spread or pick props from the object into all of our components.

By using a custom hook to share the id and label props, we can keep the FormControl component isolated and keep the logic for generating the id and label props in one place. This makes the code more readable and maintainable.

💡
Custom hooks are a powerful tool for sharing logic between components. By encapsulating common logic in a custom hook, we can keep our components pure and make them more reusable.

Conclusion

When deciding which method to use, it's important to consider the specific use case and the trade-offs of each method. By understanding the different methods of composition in React, you can create great component libraries.