DEV Community

Cover image for React Patterns: A Component with Many Content Variants
Miroslav Nikolov
Miroslav Nikolov

Posted on • Updated on • Originally published at webup.org

React Patterns: A Component with Many Content Variants

A dumb component renders any text passed while its visual appearance remains static. It is also unknown how many new content variations are about to come. What would be a useful React pattern in this scenario?

 

The Problem

Look at the right side of the picture below.

Dumb and Smart React components

It displays two lines of text and a link. You will face no issues representing it with a single component until the content becomes dynamic and texts/link need to change. In such case presentation (aka design) is the same, but content may have fluid variations.

The challenge is to organize your code, so it remains simple and allows for painless future changes.

There are three things to take care of along the way: styling, data, and business logic.

 

The Solution

It aims for flexibility and maintainability. Making future changes should be straightforward as that is the real pain point here — prepare the code for hidden information causing new uses cases to pop in.

 

Folder Structure

-- components
   |-- Box
       |-- Box.js
       |-- Box.css
       |-- components
           |-- Text1.js
           |-- Text2.js
           |-- Text3.js
           |-- Text4.js

Enter fullscreen mode Exit fullscreen mode

The solution involves two-component levels — parent component (<Box />) and several child components for each textual case (<TextN />). Note that child component names above are chosen to simplify the picture. Ideally, you should name them after each specific use case, fx. StorageEmptyText.js, StorageAboveAverageText.js, StorageFullText.js, etc.

 

Box Component

That's the parent (container) component. Its purpose is to manage business logic and styling.

// Box.js

import StorageEmptyText from "./components/StorageEmptyText";
import StorageAboveAverageText from "./components/StorageAboveAverageText";
import StorageFullText from "./components/StorageFullText";

export function Box({
  storage,
  openDialogAction,
  manageStorageAction,
  upgradeToProPlanAction
}) {
  let TextComponent = () => null;
  let handleClick = () => null;

  // 1️⃣ Use case logic: conditionally assign text component and click action
  if (storage === 0) {
    TextComponent = StorageEmptyText;
    handleClick = openDialogAction;

  } else if (storage > 50 && storage < 80) {
    TextComponent = StorageAboveAverageText;
    handleClick = manageStorageAction;

  } else if (storage >= 90) {
    TextComponent = StorageFullText;
    handleClick = upgradeToProPlanAction;
  }
  // 2️⃣ More cases to follow

  return (
    <div className="Box">
      {/* 3️⃣ A component with render prop is famous for its flexibility */}
      <TextComponent>
        {({ title, description, link }) => (
          <>
            {/* 4️⃣ Box.js (the parent) holds these elements' styling */}
            <h3>{title}</h3>
            <p>{description}</p>
            <span onClick={handleClick}>{link}</span>
          </>
        )}
      </TextComponent>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Several benefits emerge from such a structure:

  1. The markup around each text is part of Box.js so you can style it here.
  2. The use case logic is explicit.
  3. Child components are nearby.

Having texts encapsulated in <TextComponent />s instead of a function or hooks will also lighten the tests. You will need to check the className or data-test-id presence for each use case, not the actual texts.

// Box.test.js

expect(
  <Box storage={95} />,
  "when mounted",
  "to contain elements matching",
  ".StorageFullText"
);
Enter fullscreen mode Exit fullscreen mode

 

Text Components

You may have any number of these components. They share the same structure — dumb component with a render prop function to accept the texts. File names should be descriptive as that gives you a hint of what's going on by both, looking at the folder structure and maintaining the logic in Box.js

// StorageFullText.js

export default function StorageFullText({ children }) {
  return (
    <div className="StorageFullText">
      {/*
        Passes down its texts to a children() function
        That allows for custom styling in the parent
      */}
      {children({
        title: "Looks like you have too many items",
        description: "Upgrade to our Pro plan or free up more space in your box.",
        link: "See all plans"
      })}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

<StorageFullText /> uses render prop to send back data and is not aware of how texts are consumed later on. One of the reasons for using this pattern is the flexibility render prop components provide over hooks fx.

 

Final Words

Handling text components may look simple on the surface, but in some situations requires you to take a different approach and the extra step.

If a component doesn't change its visual representation and structure but may have many content variants, it makes sense to involve fewer wrappers if possible. The guiding principle is to avoid being smart about code reusability. Instead, go explicit and minimal expecting things to change.


📩

I maintain a monthly blog newsletter with more posts like this.


Top comments (2)

Collapse
 
alarid profile image
Yohann Legrand • Edited

Really interesting, thanks for sharing ! I'm really curious to test out this pattern and see how it goes when the "variant" components (the Text components in your example) tend to vary in terms of props, including some additional ones that may need to come from above ! Might be a little tricky

Collapse
 
moubi profile image
Miroslav Nikolov

That will bring some load to Box.js as it will need to know about these props.

If the overall output of the text components remains the same — meaning that at the end they still return text with the same structure — but the props change, you can do something like that:

  // Box.js

  ...

  if (storage === 0) {
    TextComponent = StorageEmptyText;
    handleClick = openDialogAction;
    TextComponentProps = { prop1: "value", prop2: "value" };

  } else if (storage > 50 && storage < 80) {
    TextComponent = StorageAboveAverageText;
    handleClick = manageStorageAction;
    TextComponentProps = { prop3: "value" };
  } 
  ...

  return (
    <div className="Box">
      <TextComponent {...TextComponentProps}>
        {({ title, description, link }) => (
          ...
        )}
      </TextComponent>
    </div>
  );
Enter fullscreen mode Exit fullscreen mode

Or render (not assign) <TextComponent /> as part of the Box's conditional logic. It is a matter of taste.

Some comments have been hidden by the post's author - find out more