Consider the infamous Hero Banner component, which has a place in all of our themes and indeed most websites on the internet today. We'll call it the "Hero Section" from now on, to fit our design terminology.
In some cases, it should have a large image or a video. Sometimes it's just nicely styled text. In other cases, it should embed within it interactive elements such as a contact form or even login functionality. Here are a few possible looks for this component:
With a featured image:
With a video:
With a contact form:
With social login:
Instead of having all the code to support these feature types within the Hero Section component itself, we can have it embed various types of child components to do the job. Then, these child components can be reused by other components.
The Hero Section, or the parent, is only be involved in specifying which children types are supported and allocating space for them, but it is the concrete child components which do their own rendering. Each of these child components may come with a completely different model to match their unique capabilities.
Since components are driven by models, this parent-child relation is expressed in the model definitions.
The Slightly Naive Approach
Here's how the model for our Hero Section may define this relation, using a field of type model for nesting sub-objects:
name: HeroSection
...
fields:
- type: string
name: title
...
- type: model
name: featuredItem
models:
- ImageBlock
- VideoBlock
- FacebookLoginBlock
- SignupBlock
This already ain't bad:
- Based on what we've learned about Atomic Design in the previous article, we're using block-type components that are made to fit into multiple section types.
- We can add more model types to the list under
models
in the future, as we build new compatible models and block components.
When a content creator edits a Hero Section and chooses which type of featured item to have, the type of that item is stored in the content object along with the item's data. When rendering the Hero Section, we could the type
attribute to delegate the rendering of the featured item to the appropriate component, via a simple helper:
function Feature(feature) {
switch (feature.type) {
case 'ImageBlock':
return <ImageBlock {...feature}/>;
case 'VideoBlock':
return <VideoBlock {...feature}/>;
case 'FacebookLoginBlock':
return <FacebookLoginBlock {...feature}/>;
case 'SignupBlock':
return <SignUpBlock {...feature}/>;
}
}
export function HeroSection(props) {
return (
<div>
{/* ... render title, subtitle, actions, and then: */}
<Feature feature={props.feature} }
</div>
);
}
There are a few things which could be much improved, though!
First, the featuredItem
field definition explicitly names the model names it supports. This is fine in some cases, but can quickly get messy:
Say you've added a new block component which is applicable as a child for the Hero Section and some other section components. This new coponent comes with its own model. To declare that applicability you'll need to add the new model's name where it fits as a child - meaning that you'll have to override the HeroSection model itself, plus any other relevant parent models. Do we really need to involve parents in this?
Second, the switch
statement is a bit silly: there should be a better way to fetch the concrete component class from the model's type we have at hand.
Third, What if you want to override an existing library component, such as the ImageBlock, with your own version: would you now have to also look for every module in the library where the original component is used, and change the import
statement in each?
Fortunately, there's a better way.
Using Model Groups
All components which are usable as a featured item within the Hero Section share a common quality - we can say that they're all "block components". The name doesn't matter, though. What does matter is the logical grouping of these components together. Here's how we apply that grouping:
- For each of the applicable child models, add the common group name in the
groups
property. For example, inImageBlock.yaml
:
name: ImageBlock
groups:
- BlockComponents
fields:
...
- In the HeroSection model, modify the definition of the
feature
field. We're now specifying applicablegroups
rather thanmodels
:
name: HeroSection
...
fields:
...
- type: model
name: featuredItem
groups:
- BlockComponents
Now, to add any new model to the BlockComponents
group we only need to update that model's definition.
You can of course make this group matching as granular as you need: models can belong in multiple groups, and fields in parent models can accept multiple groups as well.
To learn the specifics of using groups, see:
- Using the
groups
property at the model level. - Using
groups
as a field property (for fields of typemodel
,reference
andlist
).
The groups
feature answers the first of the concerns we've raised above - but what about the other two?
Resolving Components by Model Name
The proper way to instantiate a component is not by importing and using specifying hard-coded component names in your React models. Rather, here is how it's done in the actual code for the HeroSection component:
import { getComponent } from '../../components-registry'
// ...
export default function HeroSection(props) {
return (
<div>
{/* Somehere in the component tree: */}
{props.feature && (
<div className="flex-1 px-4 w-full" data-sb-field-path=".feature">
{heroFeature(props.feature)}
</div>
)}
</div>
)
}
function heroFeature(feature) {
const Feature = getComponent(feature.type)
return <Feature {...feature} />
}
// ...
Here's what happens here:
- The HeroSection component receives its content object as its
props
argument. That content object has properties for all fields defined in the HeroSection model (example), so it has afeature
property holding the child feature object. - To get the type of content stored in that child object, the code reads
feature.type
. Thetype
property always holds the model name for this object - or in other words, the content type. - The
getComponent()
function is provided insrc/components/components-registry.ts
in your project. It accepts a model name and returns a functional React component that was registered to render that model.
This, of course, raises a new series of questions: how does getComponent()
know which component to return? how do I register a new component or override any of the existing ones? We'll get to explain all that soon, but let's cover a few more of the notable features of components first.
data-sb-field-path
HTML attribute in the snippet above relates to, it's recommended to read up on annotations and how they work with components first.
Next up: User Controlled Styles.