Composition with React.cloneElement
4 min read
React.cloneElement is named appropriately. You use it to bestow additional props on a React element object. These are typically created by JSX. Given these objects actually have a stable API, it seems like a superfluous function.
Here, we are cloning the div (a React element has a .type and .props; an element’s props includes its children), and adding the className prop.
function NativeCloneElement() {
const element = <div>Text</div>;
return (
<element.type {...element.props} className="border p-4 dark:border-white" />
);
} Did you spot the mistake? This snippet may work, but it will subtly break when you use either of the two reserved props: key and ref. See for yourself:
function RefMagicTrick() {
const textRef = useRef<HTMLTextAreaElement>();
const [value, setValue] = useState("");
const textarea = (
<textarea ref={textRef} placeholder="Your essay..." defaultValue={value} />
);
React.useEffect(() => {
if (!textRef.current) return;
textRef.current.onchange = (e) =>
setValue((e.currentTarget as HTMLTextAreaElement).value);
}, []);
return (
<div className="columns-2">
<textarea.type {...textarea.props} className="p-8" />
<div>
<p>Entered value:</p>
{value || "(none)"}
</div>
</div>
);
} Entered value:
(none)When entering anything into the textarea, value is not updated, because textRef is no longer connected to the manually cloned textarea.
This is the main reason we have cloneElement. It maintains the original element’s .ref and .key properties, which are not stored inside its props, but instead have their own field on the element object. If you override ref or key with the second (config) argument, those will still be used instead.
function ReactCloneElementRef() {
const textRef = useRef<HTMLTextAreaElement>();
const [value, setValue] = useState("");
const textarea = (
<textarea ref={textRef} placeholder="Your essay..." defaultValue={value} />
);
React.useEffect(() => {
if (!textRef.current) return;
textRef.current.oninput = (e) =>
setValue((e.currentTarget as HTMLTextAreaElement).value);
}, []);
return (
<div className="columns-2">
{React.cloneElement(textarea, { className: "p-8" })}
<div>
<p>Entered value:</p>
{value || "(none)"}
</div>
</div>
);
} Entered value:
(none)By using cloneElement here, the ref is maintained. The same applies to keys.
A word of advice
Read section A word of advicecloneElement is not a commonly-used function and you’re likely to be met by confusion for wanting to use it. It can be hard for a newcomer to understand what it does. If you use it to inject required props, TypeScript won’t understand that, and will complain that those required props are missing.
If you want to use it to make an API more ergonomic, consider only using it for optional props, like className.
Cloning may seem expensive, but it’s actually quite cheap. There isn’t much overhead compared to just having more JSX elements. In the eyes of React, you are already re-creating the entire element tree with createElement calls. This is why its diffing algorithm is quite complex already. With cloneElement, you are simply modifying a createElement object before passing it to React.
Use cases
Read section Use casesTogether with the React.Children methods, you can interact with the children prop. React provides these methods as an abstraction in order to be able to change the underlying data structure used by children. You can always use React.Children.toArray if you need to use array methods or perform random access.
Wrapping children in an element
Read section Wrapping children in an elementRather than having to provide elementType and elementProps props to pass to createElement inside Elementify, you can use the cloneElement pattern to provide an element which serves as a template.
function Elementify(props: { element: ReactElement; children: string[] }) {
return (
<>
{React.Children.map(props.children, (child) =>
React.cloneElement(props.element, null, child)
)}
</>
);
} function MarketingLP() {
const translate = (key) => key;
return (
<Elementify
element={
<p className="text-lg text-red-800 underline dark:text-red-100" />
}
>
{translate("Why")}
{translate("How")}
{translate("Buy Now")}
</Elementify>
);
} Why
How
Buy Now
The second argument is used to add additional props to a cloned element. null means to not add any additional props. All arguments after the second are converted to an array of children.
Appending children
Read section Appending childrenDisclaimer here adds a child to each of the children provided to it.
function Disclaimer(props: { children: ReactElement[] }) {
return (
<>
{React.Children.map(props.children, (child) =>
React.cloneElement(child, {
children: [
...(child.props.children
? React.Children.toArray(child.props.children)
: []),
<small className="block pt-2">
Investing involves risk; you may lose money.
</small>,
],
})
)}
</>
);
} function TradeNow() {
return (
<Disclaimer>
<div className="p-4 font-bold">
You're missing out! Invest in CFDs now!
</div>
<div className="p-4 font-bold">
You're missing out! Bitcoin is going to the moon!
</div>
</Disclaimer>
);
} You could also prepend children this way, or add them anywhere in the middle (or conditionally) with toArray.
Injecting props into a child
Read section Injecting props into a childIn this example, Form provides a large abstraction around the form fields specified as children by ManagedInputs. Submit the form to see its data.
function ManagedInputs(props: { children: ReactNode[] }) {
return (
<Form>
<input name="username" placeholder="Username" />
<input name="email" placeholder="Email" />
</Form>
);
} function Form(props: { children: ReactElement[] }) {
const formData = useRef(new Map());
const [submittedFormData, setSubmittedFormData] = useState("{}");
const inputs = React.Children.map(props.children, (node) => {
const name = node.props.name;
const defaultValue = formData.current.get(name) || "";
// Note that this handler is not memoized
const onChange = (e) => {
formData.current.set(name, e.currentTarget.value);
};
return React.cloneElement(node, { defaultValue, onChange });
});
function submitFormData() {
setSubmittedFormData(
JSON.stringify(Object.fromEntries(formData.current), null, 4)
);
}
return (
<form
className="border p-2 dark:border-white"
onSubmit={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1">{inputs}</div>
<button onClick={submitFormData} className="mt-4" type="submit">
Submit
</button>
<textarea
value={submittedFormData}
disabled
className="mt-8 h-32 w-60 text-lg"
/>
</form>
);
}