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:Hero section with featured image

With a video:Hero section with featured video

With a contact form:Hero section with contact form

With social login:Hero section 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:

  1. 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.
  2. 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:

  1. For each of the applicable child models, add the common group name in the groups property. For example, in ImageBlock.yaml:
name: ImageBlock
groups:
  - BlockComponents
fields:
	...
  1. In the HeroSection model, modify the definition of the feature field. We're now specifying applicable groups rather than models:
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 type model, reference and list).

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:

  1. 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 a feature property holding the child feature object.
  2. To get the type of content stored in that child object, the code reads feature.type. The type property always holds the model name for this object - or in other words, the content type.
  3. The getComponent() function is provided in src/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.

If you don't have any idea yet what the 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.