<aside> ➡️ TLDR: Solution for modifying props of root element is here
</aside>
“root” element, in this example, refers to the most parent element (i.e. the top level element) returned from a render method. If you intend to modify some of its children props, consider looking here.
Example: we have a component that renders a <button>
, but we want our extension to change this component to show a disabled button and add event listener:
The original source code:
/** @namespace Component/CheckoutDeliveryOption/Component */
class CheckoutDeliveryOptionComponent extends React.PureComponent {
render(): ReactElement {
return (
<button
block="Button"
/>
);
}
}
The intended modification:
/** @namespace Component/CheckoutDeliveryOption/Component */
class CheckoutDeliveryOptionComponent extends React.PureComponent {
render(): ReactElement {
return (
<button
block="Button"
disabled
onMouseEnter={()=>alert('disabled')}
/>
);
}
}
Extension should not modify the original source code. This means we are forced to build a plugin here. We are working with a class, that has a namespace Component/CheckoutDeliveryOption/Component
and we intend to modify the render
method of it.
According to the plugin docs we should use memeber-method
plugin for this case:
export default {
'Component/CheckoutDeliveryOption/Component': {
/*
Not a good practice to define functions inside export, better to have it
separate. This is for demonstration purposes only.
*/
'member-method': {
render: () => (
<button
block="Button"
disabled
onMouseEnter={()=>alert('disabled')}
/>
)
}
}
}
This might work. But what if styles for a button change? Or, what if the content of a button change? We do not know what will the styling department do, or what other plugins will return, our extensions should work in all cases.
<aside>
💡 Ideally, we should consider the result of render
function execution as a black box and operate on it assuming it returns a valid type, in our case ReactElement
.
</aside>
callback
and args
arguments of the plugin to execute the original function and operate on results.Here is how, we could use it:
export default {
'Component/CheckoutDeliveryOption/Component': {
/*
Not a good practice to define functions inside export, better to have it
separate. This is for demonstration purposes only.
*/
'member-method': {
render: (args, callback) => (
React.cloneElement(
callback(...args),
{ disabled, onMouseEnter: () => alert('disabled')}
)
)
}
}
}
But, this is not safe! Sometimes, the styling team changes the return type of a function and decides to return null
. That is common, so let’s handle such a case and also let’s refactor the render method to a separate function for better readability and organization of code:
const addAlertToButton = (args, callback) => {
// vvv Get original render
const result = callback(...args);
// vvv Check if element is null or non valid
if (!React.isValidElement(result)) {
// unexpected result returned, do nothing
return result;
}
// vvv Modify element's props
return React.cloneElement(
result,
{ disabled, onMouseEnter: () => alert('disabled') }
);
}
export default {
'Component/CheckoutDeliveryOption/Component': {
/*
Good practice to have descriptive function names for plugins. Plugins'
names should describe what they're modyfing to the original function.
*/
'member-method': {
render: addAlertToButton
}
}
}
plugin/Checkout.component.plugin.js
In this example, we use cloneElement
to pass additional props (isLoadingSummary
,
removeStoreCredit
, handleStoreCreditUpdate
) to renderSummary
function result.
Note: this example, does not check for isValidElement
therefore it could break.
<aside> ➡️ TLDR: Solution for modifying props of child element is here
</aside>
Example: We have a component that renders a <button>
with a child <span>
, say we want our extension to change span’s style to display: "block"
and add event listener:
The original source code:
/** @namespace Component/CheckoutDeliveryOption/Component */
class CheckoutDeliveryOptionComponent extends React.PureComponent {
render(): ReactElement {
return (
<button
block="Button"
>
<span>
{ __('Ship here') }
</span>
</button>
);
}
}
The intended modification:
/** @namespace Component/CheckoutDeliveryOption/Component */
class CheckoutDeliveryOptionComponent extends React.PureComponent {
render(): ReactElement {
return (
<button
block="Button"
>
<span
style={{display: "block"}}
onMouseEnter={()=>alert('block')}
>
{ __('Ship here') }
</span>
</button>
);
}
}
Coming from previous knowledge, lets use a plugin and member-method
to achieve this. From React.cloneElement docs , third parameter to cloneElement
is children:
const clonedElement = cloneElement(element, props, ...children)
So lets modify the children array and render a modified span there:
const addAlertAndDisplayToSpan = (args, callback) => {
const result = callback(...args);
if (!React.isValidElement(result)) {
// unexpected result returned, do nothing
return result;
}
return React.cloneElement(
result,
result.props,
[
(
<span
style={{display: "block"}}
onMouseEnter={()=>alert('block')}
>
{ __('Ship here') }
</span>
)
]
);
}
export default {
'Component/CheckoutDeliveryOption/Component': {
/*
Good practice to have descriptive function names for plugins. Plugins'
names should describe what they're modyfing to the original function.
*/
'member-function': {
render: addAlertAndDisplayToSpan
}
}
}
But, is this a good solution? What if there were more children? What if the span was removed or altered by plugins or theme from original render altogether? How do we consider these cases? As we said in the first example:
Ideally, we should consider the result of
render
function execution as a black box and operate on it assuming it returns a valid type
But how does this fit with our specific case? Following the plugins’ philosophy, ideal goal is to try and keep everything as extensible and independent of each-other as possible, to use the least amount of assuming. Though, in our case, to modify props of a child element, at minimum we should either assume its type, or its location. Both can change and are equally fragile, this is why you should avoid nested rendering in your components as much as possible, but sometimes it is what it is.
Assuming that span (Or whatever the target element) will be rendered first in children, we can map rendered children, get the first child and modify its props using React.cloneElement
:
/*
DON'T COPY YET, THIS WILL NOT WORK, KEEP READING.
*/
const addAlertAndDisplayToSpan = (args, callback) => {
const result = callback(...args);
if (!React.isValidElement(result)) {
// unexpected result returned, do nothing
return result;
}
return React.cloneElement(
result,
result.props,
// vvv Map children
r*esult.props.children.map*(
// vvv if child's index is 0, return it with modified props
(child, i) => (i === 0 ? React.cloneElement(
child,
{
...child.props,
style: { display: 'block' },
onMouseEnter: () => alert('block'),
}
// vvv otherwise, return the original child
) : child)
)
);
};
Looks good, But! Try to run this and you will get an error that result.props.children
is not an array. This is true because react doesn’t create array for children if there is only one child as in our case. To manage this and other edge cases, we should use React.Children to map children:
const addAlertAndDisplayToSpan = (args, callback) => {
// vvv Get original render
const result = callback(...args);
// vvv Check if element is null or non valid
if (!React.isValidElement(result)) {
// unexpected result returned, do nothing
return result;
}
// vvv Clone the element, re-map its children
return React.cloneElement(
result,
result.props,
// vvv Map children
React.Children.map(
result.props.children,
// vvv if child's index is 0, return it with modified props
(child, i) => (i === 0 ? React.cloneElement(
child,
{
...child.props,
style: { display: 'block' },
onMouseEnter: () => alert('block'),
}
// vvv otherwise, return original child
) : child)
)
);
};
TODO: Embed
TODO: Description
<aside> 💡 React.cloneElement documentation
</aside>
<aside> 💡 React.Children documentation
</aside>