DEV Community

loading...
Cover image for React Design Pattern - Assemblable Card [1]

React Design Pattern - Assemblable Card [1]

didof profile image Francesco Di Donato ・Updated on ・6 min read

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

It can be used like this
composable card usage

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And its simple style

.card {
  width: 200px;
  height: 150px;
  background-color: antiquewhite;
  border-radius: 5px;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
    })
  }
Enter fullscreen mode Exit fullscreen mode

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"    // <--- !!!
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
config.js
export const blueprint = {
  Header: 1,
  Image: 0,
  Description: 2,
  Footer: 1,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
getBlankInstance.js
const getBlankInstance = (template, initialValue = 0) => {
  return Object.keys(template).reduce((blank, extention) => {
    blank[extention] = initialValue
    return blank
  }, {})
}

export default getBlankInstance
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The registerChildren method takes two parameters:

  1. the blueprint to refer to
  2. 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
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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]
  })

  ...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 πŸ‘‰πŸ“‘

If you like it, let's get in touch πŸ™ πŸ”Έ 🐦 πŸ”Ή πŸ’Ό

Discussion (0)

pic
Editor guide