Composable React

11-09-22 Travis Sanon

Learn how to take React further by embracing composability. Instead of messing around with the code for several components, modify a single source. This is the idea behind composability, and it’s easy to do with React—in fact, it’s one of the platform’s biggest benefits.

This article will teach you how to use the concept of composability to take a React component and make it more reusable and extendable. As software scales and new features are constantly added, it’s inevitable that you will, at some point, need different components with identical functionality. Wouldn’t you rather consolidate that functionally—and make changes to a single component—instead of modifying the same code for multiple components? Yeah, me too.

So What Is Composability?

Composability is components of a system combining to create higher-level structures. This is a concept that works great with React because React is a component-based library that allows for self-contained components to be combined into entirely new, higher-level components. Let’s take a look at an example. We will start by creating a basic grid of elements.

import ProductCard from './ProductCard';

const productData = [ /* Product data... */ ];

const ProductGrid = ({ products }) => {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          name={product.name}
          price={product.price}
          image={product.image}
        />
      ))}
    </div>
  )
}

const App = () => (
  <div className="App">
    <ProductGrid
      products={productData}
    />
  </div>
)

So, here we have:

  • productData: This is an array of product data
  • <ProductGrid />: This component accepts productData via props and generates a grid of ProductCard components from the data
  • <ProductCard />: This is a presentational component that displays product information

And this is the result:


We have a nice grid of products here. Cool. Now what if we also want to make an image board?

We can create a new component, duplicate the functionality of the ProductGrid component onto it, use a different UI component, and change the props to accommodate that UI component.

import ProductCard from './ProductCard;
import ImageCard from './ImageCard';

const ProductGrid = ({ products }) => {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          name={product.name}
          price={product.price}
          image={product.image}
        />
      ))}
    </div>
  )
}

const ImageBoard = ({ images }) => {
  return (
    <div className="grid">
      {images.map(data => (
        <ImageCard name={data.name} image={data.image} details={data.details} />
      ))}
    </div>
  )
}

const productData = [ /* Product data... */ ];
const imagesData = [ /* Images data... */ ];

const App = () => (
  <div className="App">
    <ProductGrid products={productData} />
    <ImageBoard images={imagesData} />
  </div>
)

Both components use the same logic. They both loop through an array of data passed via props, and render a UI component for each array item in a grid layout. The only difference between the two is that they use different UI components.

  • ProductGrid renders ProductCard UI components
  • ImageBoard renders ImageCard UI components

Simple enough, and it’s not a bad solution. However, there are issues that arise when we want to modify or extend the grid functionality of both of these components. If we want to modify the grid in any way or add the ability to pass in options like grid spacing and column count, we would have to update both components individually.

import ProductCard from './ProductCard';
import ImageCard from './ImageCard';

const ProductGrid = ({ products }) => {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          name={product.name}
          price={product.price}
          image={product.image}
        />
      ))}
    </div>
  )
}

const ImageBoard = ({ images }) => {
  return (
    <div className="grid">
      {images.map(data => (
        <ImageCard
          name={data.name}
          image={data.image}
          details={data.details}
        />
      ))}
    </div>
  )
}

const productData = [ /* Product data... */ ];
const imagesData = [ /* Images data... */ ];

const App = () => (
  <div className="App">
    <ProductGrid
      products={productData}
    />
    <ImageBoard
      images={imagesData}
    />
  </div>
)

Making Our Code More Composable

Although we were able to extend each of our component’s grid functionality, we had to write the same code in two different components, which can become repetitive and error-prone as our app grows.

Since both the ProductGrid and ImageBoard components render elements in a grid layout, we could pull that functionality into its own component and pass in whatever components and data we need to build the end result that we want. This is where we get into the idea of composability.

Again, composability is components of a system combining to create higher-level structures. In our case, we want to make the grid functionality of the ProductGrid its own component so that we can reuse that functionality in more than one specific case without replicating that same functionality for other components.

Now let’s update the ProductGrid component to be more composable.

import ProductCard from './ProductCard';

const ProductGrid = ({ products }) => {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          name={product.name}
          price={product.price}
          image={product.image}
        />
      ))}
    </div>
  )
}

First, let’s remove the UI component from the ProductGrid component.

const ProductGrid = ({ products }) => (
  <div className="grid">
    {products.map(product => ())}
  </div>
);

Then, let’s add another prop called Element. This is what we will use to pass in our UI component.

const ProductGrid = ({ products, Element }) => (
  <div className="grid">
    {products.map(product => ())}
  </div>
);

Next, we will take that Element prop and return that as a component and pass in the data from products as props. Let’s also change the prop name from products to something more generic like data.

We should also change the name of the component from ProductGrid to Grid because it is no longer supposed to render a specific kind of grid.

const Grid = ({ data, Element }) => {
  return (
    <div className="grid">
      {data.map(data => {
        return <Element {...data} />;
      })}
    </div>
  )
}

Doing this makes our grid component more composable because we can now use the same component in multiple cases and pass in whatever UI component we’d like to render as a grid.

import ProductCard from './ProductCard';

const productData = [/* Product data... */];

const ProductGrid = () => (
  <Grid
    data={productData}
    Element={ProductCard}
  />
)

And this is the end result:

Conclusion

We identified a piece of code that could be reused in the ProductGrid component, pulled the functionality from that component, put that code into its own component, and applied that component to our higher-level construct. I hope this article has given you some insight on how to add some of that sweet, sweet composability to your React code.

Related Content

UnConference: A Maturity Model for Design Systems

12-13-22

Online

If you’re planning, attempting, building, evolving, or perfecting a design system, join Ben Callahan at this event!

More Details