Configuration vs composition

ryaninvents

Ryan Kennedy

Posted on May 14, 2020

Configuration vs composition

This post was originally published on rmkennedy.com.

When designing a complex, long-lived component, there's one tradeoff that I think about constantly. Do I want to configure my component, passing in a large object with options? Or do I want to build subcomponents, which can then be composed into the final interface?

What is the tradeoff?

I'll illustrate with two possible APIs for a React dropdown component. The first implementation uses a "configured" interface. All options for the component are expressed as data passed to a prop.

<ConfiguredDropdown
  value="lab"
  items={[
    {
      displayName: 'Labrador Retriever',
      value: 'lab',
    },
    {
      displayName: 'Pit Bull Terrier',
      value: 'pit-bull',
    },
    {
      displayName: 'Boston Terrier',
      value: 'boston-terrier',
    },
  ]}
/>
Enter fullscreen mode Exit fullscreen mode

This gives you a single prop into which you pass your data. There are a few ways to do this, but depending on my data source myItems I might write something like this:

<ConfiguredDropdown
  items={myItems.map((item) => ({ displayName: item.name, value: item.id }))}
/>
Enter fullscreen mode Exit fullscreen mode

Implementation of the ConfiguredDropdown component is fairly straightforward -- iterate over each value in items and render the corresponding menu item. Done.

However, once your component makes it out into the "real world", the assumptions you made initially could break down. What if you want onClick and onHover handlers? Additional CSS for some menu items? Submenus? It's certainly possible to add new features to your component for each use case, and in some cases this may work well. However, there's an alternative implementation that could save you some effort.

Consider instead a "composed" interface. Instead of options, the dropdown items are conceptualized as content, and accordingly are passed as React children.

<Dropdown value="lab">
  <DropdownItem displayName="Labrador Retriever" value="lab" />
  <DropdownItem displayName="Pit Bull Terrier" value="pit-bull" />
  <DropdownItem displayName="Boston Terrier" value="boston-terrier" />
</Dropdown>
Enter fullscreen mode Exit fullscreen mode

Now, your data items are passed as children instead of as a data prop. The code for passing in a custom data source myItems hasn't changed much:

<Dropdown>
  {myItems.map((item) => (
    <DropdownItem key={item.id} displayName={item.name} value={item.id} />
  ))}
</Dropdown>
Enter fullscreen mode Exit fullscreen mode

The benefit here is that, as long as you pass unrecognized props to the underlying DOM element, you no longer need to anticipate your user's needs. onClick and onHover? Passed through. Custom CSS? Both style and className are available. Submenus? Possible to build using a combination of the other properties.

(aside) There's a side benefit, too; when each item is a subcomponent, each item gets the benefits of React's prop diffing.

With the configured component, the whole dropdown would re-render if you changed one item; with the composed component, React would only rerender the item that changed. This wouldn't matter in a small example like this, but on larger, more complex components you may see a performance boost.

A real-world example

My favorite example of this type of tradeoff is Handsontable vs react-virtualized.

Handsontable is an amazing library. I've used it professionally, and it has options for most things you might want to achieve with a spreadsheet in the browser. Most... but not all. Between 2017 and 2019 I was on a project to build a search application for biological data, and due to the complexity of the results display I chose Handsontable. It worked well for a while, but eventually I found myself needing features outside its customizable parameters.

If I had to write that project again, I'd reach for react-virtualized. This library offers primitives β€” AutoSizer, Grid, and ScrollSync, to name a few β€” that you compose to meet your application's needs. Granted, react-virtualized does not offer spreadsheet capabilities like Handsontable, but I could imagine an alternate API for a spreadsheet component with more composability.

Creating your own composed APIs

Here are some tactics you can use in your own codebase.

Use Context, Providers, or Services to your advantage

Complex applications often contain multiple components that must work together in sync. In my experience, unless the developer pays attention to the design, this tends to generate "God components" that render everything and manage state. These components are tightly coupled and require effort to maintain.

Many front-end frameworks offer mechanisms for synchronizing multiple components. React offers Context, Vue has provide / inject, Angular has services, and Svelte provides getContext / setContext.

If you have a special case to handle, or if you're not using one of the above frameworks, don't be afraid to use a singleton. As long as state changes are reported correctly, and you observe the rules of your chosen view layer (e.g. never mutate an object passed as a React prop) then a singleton could dramatically simplify your implementation.

Functions are the best configuration

Sometimes, your library truly does require configuration. Traditionally, this is done with a configuration object with a specific documented API. However, you may want to consider accepting functions as configuration. By passing responsibility to the user, you are not only writing less code, but also adding greater flexibility to your API. React render props are a great method for allowing consumers to customize a component, and my favorite example of a render prop API is Downshift.

Exposing a composed API as a configurable one

If you want to simplify certain use cases, it doesn't take much code to wrap a composable component and present it as a configurable component instead. Here's a suggestion of how I might implement ConfiguredDropdown based on the Dropdown and DropdownItem components above:

// Since this is just an illustration, I'm not going to do anything special with `value`.
export function ConfiguredDropdown({ items }) {
  return (
    <Dropdown>
      {items.map(({ displayName, value }) => (
        <DropdownItem key={value} displayName={displayName} value={value} />
      ))}
    </Dropdown>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is useful when you want to offer a "base" version of your component that covers 80% of the use cases, but also add an "advanced" version where users can compose to get the functionality they want.

But as always: use your own judgement

Some circumstances β€” performance goals, interoperability, other constraints β€” may change the calculus, and you may find it a better fit to design a configured rather than composed component. In small cases, you may not even notice the difference. You are the architect of your own apps, so while you should keep these points in mind, decide for yourself on a case-by-case basis which approach you want to take.

References

πŸ’– πŸ’ͺ πŸ™… 🚩
ryaninvents
Ryan Kennedy

Posted on May 14, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related