In this first post of a series, I begin the implementation of a composable React component.
It is a Card that can be expanded with sub-components thanks to the Compound Pattern 🏗
As a picnic basket, it will be a component with everything you need inside
Take a look at the code 📑 or let's get started
Init
- project created with
npx create-react-app
- streamlining to the essentials
Chapter I - The Foundation 🧱
I create a components folder. Inside there is a card folder. So here Card.js
mkdir src/components
mkdir src/components/card
touch src/components/card/Card.js
In the latter I define a class component
Card.jsx
import React from 'react'
import './style.css'
class Card extends React.Component {
render() {
return <article className='card'>{this.props.children}</article>
}
}
export default Card
And its simple style
.card {
width: 200px;
height: 150px;
background-color: antiquewhite;
border-radius: 5px;
}
I make it clear. I use the CSS for simplicity's sake; in a real project I definitely recommend an alternative (i.e. scss/sass, styled-components)
So far nothing new. Anything passed in <Card>
would be rendered inside a colored rectangle
I decide it's time to make the component extendable:
mkdir src/components/card/extentions
There are only four types of extensions available at the moment:
- Header -
touch src/components/card/extentions/Header.js
- Image -
touch src/components/card/extentions/Image.js
- Description -
touch src/components/card/extentions/Description.js
- Footer -
touch src/components/card/extentions/Footer.js
For each I create a simple functional component (I show only the header to be synthetic)
extentions/Header.jsx
const Header = ({ children }) => {
return <header>{children}</header>
}
export default Header
So I adopt the Compound Pattern in Card.js
:
- I import the sub-components
- I associate each one with a static property of the same name in the Card component
This simple possibility made me think again about the usefulness of class components 🤨
Card.jsx
import Header from './extentions/Header'
import Image from './extentions/Image'
import Description from './extentions/Description'
import Footer from './extentions/Footer'
class Card extends React.Component {
static Header = Header
static Image = Image
static Description = Description
static Footer = Footer
render() {
return <article className='card'>{this.props.children}</article>
}
}
So I use this component somewhere
App.jsx (detail)
<Card>
<Card.Header>I am the Header</Card.Header>
<Card.Description>Bio</Card.Description>
<Card.Footer>On the ground</Card.Footer>
<Card.Header>Header - again?</Card.Header>
</Card>
And actually, the various sub-components will be inserted into the parent component
I draw some observations:
- The order in which the sub-components are inserted determines the order in which they are rendered
- The presence of a sub-component is independent of that of the others
- I can omit one or more (or all)
- I can add an indefinite number of each
- The logic and style of each sub-component is limited within it
Chapter II - Census 📜
It's time to set some rules. I want each Card to respect a certain type of structure: maximum one Header, maximum one Footer, at (for the moment) no Image. However, I grant 2 Descriptions.
I need that even before the Card is mounted, a census of its sub-components takes place to ensure that this directive is respected.
In the component Card I add the following constructor
Card.jsx (detail)
constructor(props) {
super(props)
React.Children.forEach(props.children, child => {
console.log(child)
})
}
For each sub-component I get a log like
{
$$typeof: Symbol(react.element),
key: null,
ref: null,
props: { children: "I am the Header" },
type: {
...
name: "Header" // <--- !!!
}
...
}
So
child.type.name
corresponds to a string representing the sub-component associated with the child
Now that I know how to identify children, I need to define a configuration object that represents the card blueprint
touch src/components/card/config.js
config.js
export const blueprint = {
Header: 1,
Image: 0,
Description: 2,
Footer: 1,
}
The usefulness of the first capital letter will be explained in a few lines.
So I'm going to define a helper method that will come in very useful in a little while
mkdir src/utils
touch src/utils/getBlankInstance.js
getBlankInstance.js
const getBlankInstance = (template, initialValue = 0) => {
return Object.keys(template).reduce((blank, extention) => {
blank[extention] = initialValue
return blank
}, {})
}
export default getBlankInstance
What it does is receive a template (it will be the blueprint) and return an object with the same properties but with all values at 0 (optionally any other value that might be useful.)
Finally, I'm going to take a census of the children. Again I operate in a helper
touch src/utils/registerChildren.js
The registerChildren
method takes two parameters:
- the blueprint to refer to
- the actual list of children to be reviewed
The first thing it does is use getBlankInstance
based on the blueprint
provided to it to create a counter that will be updated as children are scanned
Since the properties of the blueprint coincide with the names of the sub-components (the first capital letter!) it is possible to access the properties through the square brackets notation. If I had taken a different approach, a simple switch would have had the same effect.
utils/registerChildren.js
import React from 'react'
import getBlankInstance from './getBlankInstance'
const registerChildren = (blueprint, children) => {
const counter = getBlankInstance(blueprint)
React.Children.forEach(children, child => {
const { name } = child.type
counter[name]++
})
console.log(counter)
}
export default registerChildren
In Card.js
I import the function and the blueprint it needs. So I use them in the constructor
Card.js (details)
import registerChildren from '../../utils/registerChildren'
import { blueprint } from './config'
...
constructor(props) {
super(props)
const { children } = props
if (!children) return
registerChildren(blueprint, props.children)
}
Changing the amount of sub-components (I'm referring to what happens in App.js
, where the Card component is used) I notice that the counter actually keeps track of the children and categorizes them. The only thing missing is to check that the counter respects the blueprint and that's it.
registerChildren.js
const registerChildren = (blueprint, children) => {
const counter = getBlankInstance(blueprint)
React.Children.forEach(children, child => {
const { name } = child.type
counter[name]++
})
const anomalies = Object.keys(blueprint).filter(extention => {
return counter[extention] > blueprint[extention]
})
if (Boolean(anomalies.length)) {
throw new Error(`The structure used does not respect the blueprint.
Please check ${anomalies.join(' ')}`)
}
return counter
}
So for each property of the blueprint I check that the respective value in the counter does not exceed that indicated by the blueprint. If so, the anomalous property is placed in anomalies
. If the list of anomalies is not zero, the use of the sub-components is not respected - error time!
Otherwise, I return the item, it might come in handy
The error provides an indication to who was using it erroneously, indicating in the message what the anomalies are
Since the blueprint is received from the outside, this same function would be usable in a totally new component
It is assumed that the children of Card are always the predisposed sub-components. Using any other tag (at the moment) would break the script as it would lack the name attribute under type. There is a way around this little hurdle and I'll cover it in a future post - coff Context API coff
Interlude - I Fought the Law and the Law Won
Keeping in mind that the blueprint is
config.js
export const blueprint = {
Header: 1,
Image: 0,
Description: 2,
Footer: 1,
}
Where I use the Card component
App.jsx (detail)
<Card>
<Card.Header>Twin</Card.Header>
<Card.Image>I should not be here</Card.Image>
<Card.Header>Peaks</Card.Header>
</Card>
And I am overwhelmed by the error 👾
Error: The structure used does not respect the blueprint. Please check Header Image
.
Enhancement Break - Just the way I want it
It is true that it is not possible to insert more sub-components than those foreseen for a given category. However, it is also true that at the moment it is possible to use a smaller number or even omit them altogether. Anything wrong.
However, if I wanted to be more in control I would accept a third parameter strict which, if it were true, would report as an anomaly any category that does not perfectly comply with the blueprint indications
utils/registerChildren (strict version)
const registerChildren = (blueprint, children, strict = false) => {
...
const anomalies = Object.keys(blueprint).filter(extention => {
if (strict) return counter[extention] !== blueprint[extention]
return counter[extention] > blueprint[extention]
})
...
In this case, the only use of Card would be
App.jsx (detail)
<Card>
<Card.Header>header</Card.Header>
<Card.Description>description 1</Card.Description>
<Card.Description>description 2</Card.Description>
<Card.Footer>footer</Card.Footer>
</Card>
It may or may not be useful, it only costs only a boolean 🪙
Thanks for reading, see you soon with the next chapters
Repo that I update as I write this series of posts 👉📑
Top comments (0)