Working Effectively with a Headless CMS in React – Part III

Previously we talked about decoupling the CMS from the UI and how to handle code-splitting. In the last post of this series about Headless CMS and React, we’ll talk about working with components that need to receive props from other components.

Table of Contents

This post is part of a series:
Part 1 -> https://blog.codeminer42.com/working-effectively-with-a-headless-cms-in-react/
Part 2 -> https://blog.codeminer42.com/working-effectively-with-a-headless-cms-in-react-part-ii/
Part 3 -> You are here

Foreign Props

The last problem we’ll tackle is the following:

Suppose that now both CommonContent and MediaContent must support an enter animation when they appear on the viewport. This animation has two possible variants, one where the component fades and slides in from the left, and the other where it does so from the right.

It would look something like this:

However, the intended UX is that for any landing page, content sections will either all enter from the left, or all enter from the right, or they’ll alternate, where the first enters from the left, the next from the right and so on.

If we created a prop for CmsMediaContent and CmsCommonContent to control the enter animation, it would be error-prone as content creators would have to always keep in mind this UX rule, lest they make the enter animations inconsistent.

So ideally, to prevent this kind of problem, instead of adding a prop to control the enter animation for each content section individually, we’ll add one for the CmsLandingPage instead.

Here’s the relevant portion of the updated schema:

export type LandingPage = {
  id: string;
  component: "LandingPage";
  hero: Hero;
  content: Array<CommonContent | MediaContent>;
  contentEnterAnimation: "FromLeft" | "FromRight" | "Alternate" | "None";
};

// Notice there is no prop that controls
// the enter animation for the components
// below
export type CommonContent = {
  id: string;
  component: "CommonContent";
  headline: string;
  body: string;
};

export type MediaContent = {
  id: string;
  component: "MediaContent";
  headline: string;
  media: Image | YoutubeVideo;
};

Then, we have to implement the animation logic on the UI components:

CommonContent

import cx from "./CommonContent.module.scss";
import {
  ContentEnterAnimation,
  ContentAnimationWrapper,
} from "./ContentAnimationWrapper";

export type CommonContentProps = {
  headline: string;
  body: string;
  // type ContentAnimation = "FromLeft" | "FromRight" | "None";
  enterAnimation?: ContentEnterAnimation;
};

export const CommonContent = ({
  headline,
  body,
  enterAnimation = "None",
}: CommonContentProps) => {
  return (
    // To keep things simple we'll abstract
    // the animation logic with a wrapper
    <ContentAnimationWrapper enterAnimation={enterAnimation}>
      <div className={cx.container}>
        <h3 className={cx.headline}>{headline}</h3>

        <p className={cx.body}>{body}</p>
      </div>
    </ContentAnimationWrapper>
  );
};

MediaContent

import { ReactNode } from "react";
import cx from "./MediaContent.module.scss";
import {
  ContentEnterAnimation,
  ContentAnimationWrapper,
} from "./ContentAnimationWrapper";

export type MediaContentProps = {
  headline: string;
  media: ReactNode;
  enterAnimation?: ContentEnterAnimation;
};

export const MediaContent = ({
  headline,
  media,
  enterAnimation = "None",
}: MediaContentProps) => {
  return (
    <ContentAnimationWrapper enterAnimation={enterAnimation}>
      <div className={cx.container}>
        <h3 className={cx.headline}>{headline}</h3>

        <div className={cx.imageContainer}>{media}</div>
      </div>
    </ContentAnimationWrapper>
  );
};

As the enterAnimation prop is in the UI components but not in the CMS component, content creators won’t be able to control this property for each content section individually, which is precisely what we want.

Now let’s take a look at the most important piece of the puzzle, the CmsLandingPage component:

CmsLandingPage

import { LandingPage as CmsLandingPageProps } from "@/infrastructure/cms/schemas";
import { CmsComponent } from "./CmsComponent";
import { LandingPage } from "../components/LandingPage";

export const CmsLandingPage = ({
  hero,
  content,
  enterContentAnimation,
}: CmsLandingPageProps) => {
  // How do we pass `enterAnimation` to
  // `CommonContent` and `MediaContent` to
  // UI components based on `enterContentAnimation`?

  const heroElement = <CmsComponent {...hero} />;
  const contentElement = content.map((item) => (
    <CmsComponent key={item.id} {...item} />
  ));

  return <LandingPage hero={heroElement} content={contentElement} />;
};

But alas, we have a problem here. How do we pass the enterAnimation prop based on enterContentAnimation to the UI components as we don’t have direct access to them?

If we weren’t using the CmsComponent, we would have direct access to the UI components, but then we would be undoing the very first thing we did, which was to isolate the logic that selects the component that will be rendered, and would once again have all those problems we mentioned before, so this is not an option.

Another possibility would be to instead of returning a React element from the CmsComponent, have a function like getUiComponent that returns a React component, that is, the UI component itself.

Before we continue let us talk a little about React terminology.

A React (functional) component is any function that returns react elements (i.e. "JSX").

// This is a React component
export const Component = () => {
  return <div>Hello World</div>;
};

A React element is the result of "calling" a React component through JSX.

// This is a React element
const element = <Component />;

So let’s try creating this getComponent function:

import {
  LandingPage as LandingPageProps,
  InfoPage as InfoPageProps,
  Hero as HeroProps,
  CommonContent as CommonContentProps,
  MediaContent as MediaContentProps,
  Image as ImageProps,
  YoutubeVideo as YoutubeVideoProps,
} from "@/infrastructure/cms/schemas";
import { LandingPage } from "../components/LandingPage";
import { InfoPage } from "../components/InfoPage";
import { Hero } from "../components/Hero";
import { CommonContent } from "../components/CommonContent";
import { MediaContent } from "../components/MediaContent";
import { Image } from "../components/Image";
import { YoutubeVideo } from "../components/YoutubeVideo";

export type CmsComponentProps =
  | LandingPageProps
  | InfoPageProps
  | HeroProps
  | CommonContentProps
  | MediaContentProps
  | ImageProps
  | YoutubeVideoProps;

export const getUiComponent = (props: CmsComponentProps) => {
  const Component = componentMatrix[props.component];

  return Component;
};

// The matrix now refers to the UI components
// themselves, instead of referring to the CMS components
const componentMatrix = {
  LandingPage: LandingPage,
  InfoPage: InfoPage,
  Hero: Hero,
  CommonContent: CommonContent,
  MediaContent: MediaContent,
  Image: Image,
  YoutubeVideo: YoutubeVideo,
} satisfies Record<CmsComponentProps["component"], unknown>;

CmsLandingPage

import { LandingPage as CmsLandingPageProps } from "@/infrastructure/cms/schemas";
import { getUiComponent } from "./getUiComponent";
import { LandingPage } from "../components/LandingPage";

export const CmsLandingPage = ({
  hero,
  content,
  enterContentAnimation,
}: CmsLandingPageProps) => {
  const heroComponent = getUiComponent(hero);

  const heroElement = <Hero headline={hero.headline} body={hero.body} />;

  const contentAnimationList = computeContentAnimationList(
    contentEnterAnimation,
    renderContentList.length,
  );
  const contentElement = content.map((item, index) => {
    const Content = getUiComponent(item);

    if (content.component === "CommonContent") {
      return (
        <Content
          headline={item.headline}
          body={item.body}
          enterAnimation={contentAnimationList[index]}
        />
      );
    }

    //...
    // We're having to "deserialize" the whole
    // component tree inside this component,
    // this won't work
  });

  return <LandingPage hero={heroElement} content={contentElement} />;
};

const computeContentAnimationList = (
  contentEnterAnimation: LandingPageContentEnterAnimation,
  contentListSize: number,
) => {
  const strategies: Record<
    LandingPageContentEnterAnimation,
    () => Array<ContentEnterAnimation>
  > = {
    Alternate: () =>
      Array.from({ length: contentListSize }, (_, index) =>
        index % 2 === 0 ? "FromLeft" : "FromRight",
      ),
    None: () => Array(contentListSize).fill("None"),
    FromLeft: () => Array(contentListSize).fill("FromLeft"),
    FromRight: () => Array(contentListSize).fill("FromRight"),
  };

  return strategies[contentEnterAnimation]();
};

As you can see, this doesn’t work, because by returning the UI component itself from getUiComponent, we must deserialize the whole component tree inside CmsLandingPage.

There’s a third possibility, which is to use higher order components, where we’ll have a getCmsComponent that returns a CMS component instead of a UI component, but there’s a catch — the CMS component that’s returned by this function will already have the props we pass to it CMS injected while the CMS component itself will receive additional props.

I know this sounds confusing, but when we see it in practice it becomes much clearer.

Before I show you what this looks like with the examples we’ve been working with, let me first give you a simpler example.

Let’s say we have a Button component like this:

import { ReactNode } from "react";

export type ButtonProps = {
  color: "red" | "blue" | "yellow";
  icon: ReactNode;
  text: string;
};

export const Button = ({ color, icon, text }: ButtonProps) => {
  const style = { backgroundColor: color };

  return (
    <button style={style}>
      {icon} {text}
    </button>
  );
};

And then let’s say we want a function that would return a button component with both the color and icon already injected so that this new component would only receive the text prop:

type GetSpecializedButtonParameters = {
  color: "red" | "blue" | "yellow";
  icon: ReactNode;
};

type SpecializedButtonProps = {
  text: string;
};

const getSpecializedButton =
  ({ color, icon }: GetSpecializedButtonParameters) =>
  ({ text }: SpecializedButtonProps) => {
    return <Button color={color} icon={icon} text={text} />;
  };

And then it would be used like this:

const InfoButton = getSpecializedButton({
  color: "blue",
  icon: <Icon name="info" />,
});

const infoButton = <InfoButton text="Some info" />;

Before trying to understand how this pattern will help us solve our problem, focus on the mechanism first and try to understand it. The core idea is to have some props that will be "fixed" in the component, and we’ll only be able to pass a selected subset of the original props.

Moving forward, we’ll do the same thing for CMS components, where we’ll divide the props it receives into two subsets: own props and foreign props.

A CMS component’s own props are the props that it receives from its own CMS counterpart, that is, the props that belong to the CMS component schema.

A CMS component’s foreign props are the props it receives from "other means", be it from other CMS components’ own props, or from values that are created inside a CMS component, like a ref, or a state.

So let’s update our CMS components according to this philosophy.

First, let’s take a look at CmsCommonContent and CmsMediaContent:

import { CommonContent as CmsCommonContentOwnProps } from "@/infrastructure/cms/schemas";
import { CommonContent } from "../components/CommonContent";
import { ContentEnterAnimation } from "../components/ContentAnimationWrapper";

export type CmsCommonContentForeignProps = {
  enterAnimation: ContentEnterAnimation;
};

// This is essentially a component factory
export const makeCmsCommonContent =
  // These props come from this component's CMS
  // counterpart

    ({ headline, body }: CmsCommonContentOwnProps) =>
    // These props come from somewhere else,
    // and in the current scenario, they'll come
    // from the CmsLandingPage, but they could come from
    // any other "place" as well
    ({ enterAnimation }: CmsCommonContentForeignProps) => {
      return (
        <CommonContent
          headline={headline}
          body={body}
          enterAnimation={enterAnimation}
        />
      );
    };
import { MediaContent as CmsMediaContentOwnProps } from "@/infrastructure/cms/schemas";
import { MediaContent } from "../components/MediaContent";
import { ContentEnterAnimation } from "../components/ContentAnimationWrapper";
import { getCmsComponent } from "./getCmsComponent";

export type CmsMediaContentForeignProps = {
  enterAnimation: ContentEnterAnimation;
};

export const makeCmsMediaContent =
  ({ headline, media }: CmsMediaContentOwnProps) =>
  ({ enterAnimation }: CmsMediaContentForeignProps) => {
    // This function returns a React component
    const Media = getCmsComponent(media);

    return (
      <MediaContent
        headline={headline}
        media={<Media />}
        enterAnimation={enterAnimation}
      />
    );
  };

For all other components that we have so far, their foreign props are non-existent, but to comply with how getCmsComponent works, we still have to update them as well.

For brevity, I won’t show every component, but I’ll at least show the LandingPage, which is where part of the trick is:

import { LandingPage as CmsLandingPageOwnProps } from "@/infrastructure/cms/schemas";
import { LandingPage } from "../components/LandingPage";
import {
  getCmsComponent,
  getCmsComponentRendererList,
} from "./getCmsComponent";
import { ContentEnterAnimation } from "../components/ContentAnimationWrapper";

export const makeCmsLandingPage =
  ({ hero, content, contentEnterAnimation }: CmsLandingPageOwnProps) =>
  // No foreign props
  () => {
    const Hero = getCmsComponent(hero);

    const contentAnimationList = computeContentAnimationList(
      contentEnterAnimation,
      renderContentList.length,
    );
    const content = content.map((item, index) => {
      const Content = getCmsComponent(item);

      // Here we only need to pass the **foreign props**
      // to this component, as its **own** props are already being
      // injected.
      return (
        <Content key={item.id} enterAnimation={contentAnimationList[index]} />
      );
    });

    return (
      <LandingPage
        hero={<Hero />}
        content={content}
        contentEnterAnimation={contentEnterAnimation}
      />
    );
  };

const computeContentAnimationList = (
  contentEnterAnimation: LandingPageContentEnterAnimation,
  contentListSize: number,
) => {
  const strategies: Record<
    LandingPageContentEnterAnimation,
    () => Array<ContentEnterAnimation>
  > = {
    Alternate: () =>
      Array.from({ length: contentListSize }, (_, index) =>
        index % 2 === 0 ? "FromLeft" : "FromRight",
      ),
    None: () => Array(contentListSize).fill("None"),
    FromLeft: () => Array(contentListSize).fill("FromLeft"),
    FromRight: () => Array(contentListSize).fill("FromRight"),
  };

  return strategies[contentEnterAnimation]();
};

Last but not least, here’s getCmsComponent‘s implementation:

import {
  LandingPage as LandingPageProps,
  InfoPage as InfoPageProps,
  Hero as HeroProps,
  CommonContent as CommonContentProps,
  MediaContent as MediaContentProps,
  Image as ImageProps,
  YoutubeVideo as YoutubeVideoProps,
} from "@/infrastructure/cms/schemas";
import { makeCmsLandingPage } from "./CmsLandingPage";
import { makeCmsInfoPage } from "./CmsInfoPage";
import { makeCmsHero } from "./CmsHero";
import { makeCmsCommonContent } from "./CmsCommonContent";
import { makeCmsMediaContent } from "./CmsMediaContent";
import { makeCmsImage } from "./CmsImage";
import { makeCmsYoutubeVideo } from "./CmsYoutubeVideo";

export type CmsComponentProps =
  | LandingPageProps
  | InfoPageProps
  | HeroProps
  | CommonContentProps
  | MediaContentProps
  | ImageProps
  | YoutubeVideoProps;

export const getCmsComponent = <ComponentName extends CmsComponentName>(
  props: SpecificCmsComponentProps<ComponentName>,
) => {
  const makeCmsComponent = cmsComponentFactoryMatrix[props.component];

  // Recall that a CMS component factory
  // receives its own props, and thus it's
  // here that we pass them
  const CmsComponent = makeCmsComponent(props as any);

  return CmsComponent as ReturnType<
    (typeof cmsComponentFactoryMatrix)[ComponentName]
  >;
};

type CmsComponentName = CmsComponentProps["component"];

type SpecificCmsComponentProps<ComponentName extends CmsComponentName> =
  CmsComponentProps & { component: ComponentName };

const cmsComponentFactoryMatrix = {
  LandingPage: makeCmsLandingPage,
  InfoPage: makeCmsInfoPage,
  Hero: makeCmsHero,
  CommonContent: makeCmsCommonContent,
  MediaContent: makeCmsMediaContent,
  Image: makeCmsImage,
  YoutubeVideo: makeCmsYoutubeVideo,
} satisfies Record<CmsComponentName, unknown>;

Unfortunately, this approach has one serious flaw.

Creating Components Dynamically

Creating components dynamically inside the render (which is exactly what we’re doing) is a no-go in React.

Let me show you an isolated example to clarify what I mean by "creating components dynamically inside the render":

export const Component = () => {
  // This component is being created
  // dynamically inside the render,
  // so each time `Component` is rendered,
  // we end up with a **different**
  // `DynamicComponent`, which has a new
  // reference
  const DynamicComponent = () => {
    return <div>Hello World</div>;
  };

  return <DynamicComponent />;
};

The problem with this approach is that DynamicComponent will be remounted every time Component rerenders, which has three undesirable side effects that happen for each remount:

  1. The DOM elements that are rendered by DynamicComponent will be removed and then recreated and reinserted.
  2. DynamicComponent will reset whatever state is declared within it or its descendants.
  3. All useEffects and useLayoutEffects called in DynamicComponent or its descendants will be re-run along with their cleanups.

If you want to see these side effects in practice, check out this demo:

https://stackblitz.com/edit/vitejs-vite-tfgegg

const Component = () => {
  const [count, setCount] = useState(0);

  const DynamicComponent = () => {
    return <OtherComponent count={count} />;
  };

  return (
    <>
      <div>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </div>
      <DynamicComponent />
    </>
  );
};

type OtherComponentProps = {
  count: number;
};

const OtherComponent = ({ count }: OtherComponentProps) => {
  const [text, setText] = useState('');

  useEffect(() => {
    console.log('DynamicComponent Mounted!');

    return () => console.log('DynamicComponent Unmounted!');
  }, []);

  return (
    <div>
      <input value={text} onChange={(event) => setText(event.target.value)} />

      <div>Count is {count}</div>
    </div>
  );
};

In this demo, notice that every time we click the "Increment" button, we change the state and thus make Component rerender, which then causes DynamicComponent to remount and because of that text resets to its initial value, and both useEffect and its cleanup run, despite its dependency array being empty.

Fortunately, there’s a clever way to circumvent this limitation, by employing render functions.

Render Functions to the Rescue

To keep this semantics of creating a component that already has some props injected and only receives a subset of its props as arguments without this remounting issue, we’ll use render functions.

A render function is any function that returns React elements:

type RenderParameters = {
  text: string;
};

// This is a render function
const render = ({ text }: RenderParameters) => {
  return <div>{text}</div>;
};

You might be looking at this and thinking: "How is this any different from a React component?".

As always, the devil is in the details.

Before we delve into these details, look at what happens in our demo when we replace the component that’s being created dynamically by a render function:

https://stackblitz.com/edit/vitejs-vite-tqngmq

const Component = () => {
  const [count, setCount] = useState(0);

  const render = () => {
    return <OtherComponent count={count} />;
  };

  return (
    <>
      <div>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </div>
      {render()}
    </>
  );
};

type OtherComponentProps = {
  count: number;
};

const OtherComponent = ({ count }: OtherComponentProps) => {
  const [text, setText] = useState("");

  useEffect(() => {
    console.log("DynamicComponent Mounted!");

    return () => console.log("DynamicComponent Unmounted!");
  }, []);

  return (
    <div>
      <input value={text} onChange={(event) => setText(event.target.value)} />

      <div>Count is {count}</div>
    </div>
  );
};

Now, OtherComponent is not being remounted anymore, and thus there are no unnecessary DOM updates, the state is preserved and effects do not run.

Back to render functions, let me explain how this solves the problem.

Render functions and React components are indeed very similar, at least on a syntactical level, however, there’s a key difference in regards to how each of them is called.

React components are called through JSX (), whereas render functions are **called** as ordinary functions (render({ text: "Hello" })`).

One might think that both of these are equivalent, as many times they yield the same result, however, there’s a very important difference that’s concealed by JSX.

Whenever we call a component through JSX, after transpilation, this is the resulting code:

Before transpilation:

<Component text={"Some text"} />

After transpilation:

createElement(Component, { text: "Some text" });

That is, anytime we call a React component through JSX, there’s a hidden createElement call.

Now, let’s compare a React component with a render function using the transpiled version:

createElement(Component, { text: "Some text" });

render({ text: "Some text" });

When we call createElement, we’re creating a React element whose type (which is an attribute of the React element with the same name) is the function we pass as the first argument, which is the React component.

During reconciliation, React compares the current element tree with the previous one, and to understand whether a given element is "the same" in both trees, it checks the element type by reference.

So, considering that in the first demo, we’re recreating a new DynamicComponent in each render, this means that we’re passing a different reference each time we call createElement, and thus React thinks that it is getting an element of a different type each time (because it effectively is).

When calling a render function, there’s no createElement call for the render function itself, only for its children, and thus the render function serves solely as a way to capture some variables in the closure so that we can pass it around with these variables injected.

With this technique, we can finally go back to the CMS and solve the original problem satisfactorily.

Foreign Props with Render Functions

After this whole exposition, instead of creating components dynamically to deal with foreign props, we’ll use render functions instead.

To do that, we’ll first change CMS components factories to render functions factories:

CmsCommonContent:

import { CommonContent as CmsCommonContentOwnProps } from "@/infrastructure/cms/schemas";
import dynamic from "next/dynamic";
import { ContentEnterAnimation } from "../components/ContentAnimationWrapper";

const CommonContent = dynamic(() =>
  import("../components/CommonContent").then((m) => m.CommonContent),
);

export type CmsCommonContentForeignProps = {
  enterAnimation: ContentEnterAnimation;
};

export const makeRenderCmsCommonContent =
  ({ headline, body }: CmsCommonContentOwnProps) =>
  ({ enterAnimation }: CmsCommonContentForeignProps) => {
    return (
      <CommonContent
        headline={headline}
        body={body}
        enterAnimation={enterAnimation}
      />
    );
  };

CmsMediaContent:

import { MediaContent as CmsMediaContentOwnProps } from "@/infrastructure/cms/schemas";
import dynamic from "next/dynamic";
import { getCmsComponentRenderer } from "./getCmsComponentRenderer";
import { ContentEnterAnimation } from "../components/ContentAnimationWrapper";

const MediaContent = dynamic(() =>
  import("../components/MediaContent").then((m) => m.MediaContent),
);

export type CmsMediaContentForeignProps = {
  enterAnimation: ContentEnterAnimation;
};

export const makeRenderCmsMediaContent =
  ({ headline, media }: CmsMediaContentOwnProps) =>
  ({ enterAnimation }: CmsMediaContentForeignProps) => {
    const renderMedia = getCmsComponentRenderer(media);
    const mediaElement = renderMedia();

    return (
      <MediaContent
        headline={headline}
        media={mediaElement}
        enterAnimation={enterAnimation}
      />
    );
  };

Then, getCmsComponent becomes getCmsComponentRenderer:

import { Fragment } from "react";
import {
  LandingPage as LandingPageProps,
  InfoPage as InfoPageProps,
  Hero as HeroProps,
  CommonContent as CommonContentProps,
  MediaContent as MediaContentProps,
  Image as ImageProps,
  YoutubeVideo as YoutubeVideoProps,
} from "@/infrastructure/cms/schemas";
import { makeRenderCmsLandingPage } from "./CmsLandingPage";
import { makeRenderCmsInfoPage } from "./CmsInfoPage";
import { makeRenderCmsHero } from "./CmsHero";
import { makeRenderCmsCommonContent } from "./CmsCommonContent";
import { makeRenderCmsMediaContent } from "./CmsMediaContent";
import { makeRenderCmsImage } from "./CmsImage";
import { makeRenderCmsYoutubeVideo } from "./CmsYoutubeVideo";

export const getCmsComponentRenderer = <ComponentName extends CmsComponentName>(
  props: SpecificCmsComponentProps<ComponentName>,
) => {
  const makeRenderCmsComponent = cmsComponentRendererMatrix[props.component];

  if (!makeRenderCmsComponent) {
    return () => <FallbackComponent />;
  }

  const renderCmsComponent = makeRenderCmsComponent(props as any);

  return renderCmsComponent as ReturnType<
    (typeof cmsComponentRendererMatrix)[ComponentName]
  >;
};

// As we're now using render functions,
// we cannot pass keys directly to components,
// so it's convenient to have a function
// that deals with lists of components
// and does that for us
export const getCmsComponentRendererList = <
  ComponentName extends CmsComponentName,
>(
  propsList: Array<SpecificCmsComponentProps<ComponentName>>,
) => {
  const renderCmsComponentList = propsList.map((ownProps) => {
    const renderCmsComponent = getCmsComponentRenderer(ownProps) as ReturnType<
      (typeof cmsComponentRendererMatrix)[ComponentName]
    >;

    return (foreignProps: Parameters<typeof renderCmsComponent>[0]) => (
      <Fragment key={ownProps.id}>
        {renderCmsComponent(foreignProps as any)}
      </Fragment>
    );
  });

  return renderCmsComponentList;
};

const FallbackComponent = () => null;

export type CmsComponentProps =
  | LandingPageProps
  | InfoPageProps
  | HeroProps
  | CommonContentProps
  | MediaContentProps
  | ImageProps
  | YoutubeVideoProps;

export type CmsComponentName = CmsComponentProps["component"];

export type SpecificCmsComponentProps<ComponentName extends CmsComponentName> =
  Extract<CmsComponentProps, { component: ComponentName }>;

export const cmsComponentRendererMatrix = {
  LandingPage: makeRenderCmsLandingPage,
  InfoPage: makeRenderCmsInfoPage,
  Hero: makeRenderCmsHero,
  CommonContent: makeRenderCmsCommonContent,
  MediaContent: makeRenderCmsMediaContent,
  Image: makeRenderCmsImage,
  YoutubeVideo: makeRenderCmsYoutubeVideo,
} satisfies Record<CmsComponentProps["component"], unknown>;

Finally, the Landing Page component:

CmsLandingPage:

import { LandingPage as CmsLandingPageOwnProps } from "@/infrastructure/cms/schemas";
import dynamic from "next/dynamic";
import {
  getCmsComponentRenderer,
  getCmsComponentRendererList,
} from "./getCmsComponentRenderer";
import { ContentEnterAnimation } from "../components/ContentAnimationWrapper";

const LandingPage = dynamic(() =>
  import("../components/LandingPage").then((m) => m.LandingPage),
);

export const makeRenderCmsLandingPage =
  ({ hero, content, contentEnterAnimation }: CmsLandingPageOwnProps) =>
  () => {
    const renderHero = getCmsComponentRenderer(hero);

    const contentAnimationList = computeContentAnimationList(
      contentEnterAnimation,
      content.length,
    );
    const renderContentList = getCmsComponentRendererList(content);
    const contentElement = renderContentList.map((render, index) =>
      render({ enterAnimation: contentAnimationList[index] }),
    );

    return <LandingPage hero={renderHero()} content={contentElement} />;
  };

const computeContentAnimationList = (
  contentEnterAnimation: CmsLandingPageOwnProps["contentEnterAnimation"],
  contentListSize: number,
) => {
  const strategies: Record<
    CmsLandingPageOwnProps["contentEnterAnimation"],
    () => Array<ContentEnterAnimation>
  > = {
    Alternate: () =>
      Array.from({ length: contentListSize }, (_, index) =>
        index % 2 === 0 ? "FromLeft" : "FromRight",
      ),
    None: () => Array(contentListSize).fill("None"),
    FromLeft: () => Array(contentListSize).fill("FromLeft"),
    FromRight: () => Array(contentListSize).fill("FromRight"),
  };

  return strategies[contentEnterAnimation]();
};

It might not be evident in the examples, but this approach is also fully type-safe, in the sense that the foreign props that are expected by a given render function are the intersection of all the foreign props of possible CMS components for a given slot.

For instance, if we added a backgroundColor foreign prop for makeRenderCmsMediaContent, then Typescript would force us to pass that prop when calling the render function for the content within makeRenderCmsLandingPage, as we don’t know in compile time whether we’re getting a CommonContent or a MediaContent and thus we have to provide foreign props for both of them.

Foreign Props at the UI Layer

So far, we placed the responsibility of controlling the animation of content sections on the CMS component, but it’s also possible to move it to the UI component itself.

One of the reasons why we would consider doing it is to have this logic be a part of LandingPage, and thus when using this component outside of a CMS context, this logic would also be there.

To do this, we’ll need one last piece of abstraction, but before I show it to you, let’s visualize the problem first.

Let’s recap the implementation for LandingPage:

import { ReactNode } from "react";

export type LandingPageProps = {
  hero: ReactNode;
  content: ReactNode;
};

export const LandingPage = ({ hero, content }: LandingPageProps) => {
  return (
    <div>
      {hero}

      {content}
    </div>
  );
};

What we want for LandingPage to be able to control the enterAnimation for the content sections it receives.

However, to do that, LandingPage would need to pass this prop to the components that render the content sections, but it only receives the elements.

The solution is once again the render function, so that instead of receiving the elements for the content sections, LandingPage will receive a list of render functions that have all props but enterAnimation already injected, and thus they will receive this prop from LandingPage:

import { ReactNode } from "react";
import { ContentEnterAnimation } from "./ContentAnimationWrapper";

export type LandingPageProps = {
  hero: ReactNode;
  renderContentList: Array<
    (props: { enterAnimation: ContentEnterAnimation }) => JSX.Element
  >;
  contentEnterAnimation: LandingPageContentEnterAnimation;
};

export type LandingPageContentEnterAnimation =
  | "Alternate"
  | "None"
  | "FromLeft"
  | "FromRight";

export const LandingPage = ({
  hero,
  renderContentList,
  contentEnterAnimation,
}: LandingPageProps) => {
  const contentAnimationList = computeContentAnimationList(
    contentEnterAnimation,
    renderContentList.length,
  );

  return (
    <div>
      {hero}

      {renderContentList.map((renderContent, index) =>
        renderContent({
          enterAnimation: contentAnimationList[index],
        }),
      )}
    </div>
  );
};

const computeContentAnimationList = (
  contentEnterAnimation: LandingPageContentEnterAnimation,
  contentListSize: number,
) => {
  const strategies: Record<
    LandingPageContentEnterAnimation,
    () => Array<ContentEnterAnimation>
  > = {
    Alternate: () =>
      Array.from({ length: contentListSize }, (_, index) =>
        index % 2 === 0 ? "FromLeft" : "FromRight",
      ),
    None: () => Array(contentListSize).fill("None"),
    FromLeft: () => Array(contentListSize).fill("FromLeft"),
    FromRight: () => Array(contentListSize).fill("FromRight"),
  };

  return strategies[contentEnterAnimation]();
};

And then, at the CMS component:

import { LandingPage as CmsLandingPageOwnProps } from "@/infrastructure/cms/schemas";
import cx from "./LandingPage.module.scss";
import { LandingPage } from "../components/LandingPage";
import {
  getCmsComponentRenderer,
  getCmsComponentRendererList,
} from "./getCmsComponentRenderer";

export const makeRenderCmsLandingPage =
  ({ hero, content, contentEnterAnimation }: CmsLandingPageOwnProps) =>
  () => {
    const renderHero = getCmsComponentRenderer(hero);

    const renderContentList = getCmsComponentRendererList(content);

    return (
      <LandingPage
        hero={renderHero()}
        renderContentList={renderContentList}
        contentEnterAnimation={contentEnterAnimation}
      />
    );
  };

And we’re done!

Conclusion

In this post series, we’ve shown problems that arise when working with Headless CMSes and explored patterns that address these problems.

Once again keep in mind that you don’t need to apply all these patterns at once, but rather, you should gradually apply them as the need arises.

We want to work with you. Check out our "What We Do" section!