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!
Top comments (2)
I think a more general solution for what you’re describing is hoist non react statics. It’ll do this work for you without passing in the compound component props you want (I haven’t tested this case since I’m reading on a phone) but I’ve used this in the past for similar scenarios, and it’s a staple of HOC development for this reason. Because at that point it really is easier and more clear of what’s going on to do:
Instead of:
Hi Devin,
Thank you for sharing! I was not aware of the existence of this package and am really curious to understand how it works below.