Merge High Order Component and Compound Pattern
The Compound Pattern allows you to associate one or more sub-components to a component. These can be repeated and reallocated. Above all, they allow you to encapsulate the structure, style and logic related to a portion of the UI.
The High Order Component is the extension in the React context of the High Order Function. Basically it is a function that wraps a component and enhances it and/or injects additional functionality.
Have you ever tried to use the second over the first? If so, you probably realized that React will complain. I'll tell you more - he's right.
Steps
- Create Compound Component (more)
- Create High Order Component (more)
- Merging... it fails!
- Reasoning to the solution
- Abstract away the problem
If you are already aware of both patterns skip to step 3
To better understand the problem, therefore the solution, we use some code. These are deliberately simple components, precisely because I hope the focus of attention falls on how they connect rather than on what they do.
1. Create Compound Component
A Card
component to be used in the following way:
<Card>
<Card.Header>Riso, Patate e Cozze</Card.Header>
<Card.Body more buy>
<h6>Ingredients:</h6>
<ul>
<li>Rice</li>
<li>Potatoes</li>
<li>Mussels</li>
</ul>
</Card.Body>
</Card>
Implemented like this:
function Card({ children }) {
return <article>{children}</article>
}
function Header({ children }) {
return (
<header>
<h4>{children}</h4>
</header>
)
}
function Body({ children }) { ... }
Card.Header = Header // The magic of Compound Pattern
Card.Body = Body // is all here
export default Card
Create High Order Component (HOC)
A HOC can do it all. It can wrap a component with a Provider, a Router, it can also just add color here and there or even completely distort its props. For simplicity, our withAnalytics
will simply print a specific prop of the wrapped component to the console.
function withAnalytics(Component) {
return function WrappedComponent(props) {
// mock analytics call, add props as payload, etc.
console.log('Send Analytics', JSON.stringify(props.analytics))
return <Component {...props} />
}
}
export default withAnalytics
And where Card
is used we add:
<Card analytics={{ id: '123', name: 'rpc' }}>
3. Merging... it fails!
All the pieces are there. We just need to wrap Card
withwithAnalytics
.
export default withAnalytics(Card)
And crash! So many errors in console!
Let's try to remove the sub-components in Card
.
<Card analytics={{ id: '123', name: 'rpc' }}>
{/* <Card.Header>Riso, Patate e Cozze</Card.Header>
<Card.Body more buy>
<h6>Ingredients</h6>
<ul>
<li>Rice</li>
<li>Potatoes</li>
<li>Cozze</li>
</ul>
</Card.Body> */}
</Card>
The error went away. So it's something to do with assigning sub-components as static properties on Card
.
Let's analyze the Card
export.
Previously it was export default Card
. So we were exporting a function, Card
, with the associated Header
and Body
.
It is now export default withAnalytics(Card)
. We are exporting what the withAnalytics
function returns. And what is it about?
function withAnalytics(Component) {
return function WrappedComponent(props) {
console.log('Send Analytics', JSON.stringify(props.analytics))
return <Component {...props} />
}
}
It's a function, WrappedComponent
, which accepts props... wait a minute, it's a component! Not only that - it is the component we have in our hands where we import it.
Here's the problem! Because of the HOC, where we use <Card>
we are not referring to function Card()
(the one defined at step 1), but to funtion WrappedComponent
!
It is on it that we should define the sub-components!
4. Reasoning to the solution
We can't do something like:
WrappedComponent.Header = Header
Or rather: it is what we need to happen, but it must happen dynamically. Just enable withAnalytics
to receive a set of sub-components from the file that uses it.
function withAnalytics(Component, compounds) {
function WrappedComponent(props) {
console.log('Send Analytics', JSON.stringify(props.analytics))
return <Component {...props} />
}
Object.entries(compounds).forEach(([name, component]) => {
WrappedComponent[name] = component
})
return WrappedComponent
}
And where we export Card
:
export default withAnalytics(Card, { Header, Body })
Since withAnalytics
does not know how many compounds to attach to theWrappedComponent
, nor the name, it is sufficient to iterate for each of them and exploit the structure {'component-name': 'actual-component'}
.
if it is not clear to you, just print the
name
andcomponent
inside theforEach
- you'll see.
Done. Now you can use the HOC on a component built using Compound Pattern.
But, if you feel like it, there is more.
5. Abstract away the problem
Is it possible to abstract away the sub-component assignment so that the body function of any High Order Component is concerned only with its own functionality? Yes.
We build a decorator whose purpose is to make dependencies injection of the various compounds. In this way when we build a HOC we don't have to worry about managing the compounds when we want to use it on a component created with Compound Pattern.
function decorateHOCWithStaticProps(hoc) {
return function execHOC(Component, compounds) {
const c = hoc(Component)
Object.entries(compounds).forEach(([name, component]) => {
c[name] = component
})
return c
}
}
This will allow us to revert withAnalytics
. Now it deals only with its issues. It no longer handles compounds
.
function withAnalytics(Component) {
return function WrappedComponent(props) {
console.log('Send Analytics', JSON.stringify(props.analytics))
return <Component {...props} />
}
}
export default withAnalytics
We keep exporting
withAnalytics
as default because it is sufficient as is when we want to apply it on a "Non-Compound Component".
When instead we want to apply it on a Compound Component:
export default withAnalytics
export const withAnalyticsCompound = decorateHOCWithStaticProps(withAnalytics)
Our HOC,
withAnalytics
, is "stored inside"decorateHOCWithStaticProps
. ThewithAnalyticsCompound
variable therefore corresponds to thegetCompounds
function.
Where we define and export the Compound Component Card
:
import { withAnalyticsCompound } from 'somewhere'
function Card({ children }) { ... }
export default withAnalyticsCompound(Card, { Header, Body })
When we will import Card from '...'
we're actually getting what the function returns. Making the parameters explicit can help us understand.
function decorateHOCWithStaticProps(hoc) {
// where hoc = withAnalytics
return function execHOC(Component, compounds) {
// where Component = Card
// where compounds = { 'Header': Header, 'Body': Body }
// wrap Card with withAnalytics but, before returning it...
const c = hoc(Component)
// c['Header'] = Header
// c['Body'] = Body
Object.entries(compounds).forEach(([name, component]) => {
c[name] = component
})
return c
}
}
In this way we have abstracted the resolution of the problem, solving it once and for all.
When you create a HOC and you want to make sure that it can also be used on Compound Components you just need:
- In addition to the default, also export a version of the HOC processed by
decorateHOCWithStaticProps
- Where you export the Compound Component, import the processed version of your HOC.
- Forget about the problem: use it as if it were an ordinary HOC, but pass the sub-components to it as a second argument.
Contacts
Hope you find all of this useful. If you feel like it, let's get in touch!