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.
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:
- User still has control in case it’s needed
- It works even with a more complex element tree
There are still problems, though:
- Button component need to implement the use of context which makes the Button less isolated
- It’s still quite implicit so it requires a very good documentation and users need to be educated about it.
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.
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.
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.