Creating Decorator Components with React.cloneElement

August 15th, 2020

Decorator components allow you to attach additional responsibilities to a child component dynamically, providing a flexible way to enhance children without writing new components. (Mostly paraphrasing the "Gang of Four" definition here)

Let's say for instance you want to send analytics every time an input component changes. Your UI kit includes a variety of input components: <TextInput />, <Dropdown />, <DatePicker />, etc. You could enhance the onChange() prop for each component individually, but this quickly becomes inconsistent if you need to add or remove functionality. You could create a Higher-Order Component to wrap around the input components, but then you'd be coupling the analytics to the component itself.

To keep the concerns separate and easily changeable, the option I'll show below is to create a decorator component that enhances its children with a callback function, without caring what the child is (and without the child knowing anything about the parent).

The Decorator Component

The following is a wrapper I've arbitrarily called <AnalyticsDecorator /> but you could use it to enhance the child's onChange prop however you wish:

import React, { Fragment, cloneElement } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import noop from 'lodash/noop';

const AnalyticsDecorator = ({
  children,
  callback,
}) => {
  const childOnChange = get(children, 'props.onChange', noop);

  const enhancedOnChange = (...args) => {
    callback(...args);
    childOnChange(...args);
  };

  return children && cloneElement(children, { onChange: enhancedOnChange });
};

AnalyticsDecorator.displayName = 'AnalyticsDecorator';

AnalyticsDecorator.propTypes = {
  callback: PropTypes.func,
  children: PropTypes.node.isRequired,
};

AnalyticsDecorator.defaultProps = {
  callback: noop,
};

export default AnalyticsDecorator;

Describing what happens above:

  1. The component dives into its children to reference the original onChange function
  2. It creates an enhancedOnChange() that passes all the arguments to both the callback() you send for analytics (or whatever), and also invokes the original onChange() function
  3. Then it renders the children, with the enhancedOnChange() making its way to the child component as plain ol' onChange

Using the <AnalyticsDecorator /> Component

The <AnalyticsDecorator /> component can now enhance any child that uses an onChange prop.

<AnalyticsDecorator callback={doSomeAnalytics}>
  <Input name="myInput" value={myValue} onChange={doAthing} />
</AnalyticsDecorator>

When the <Input /> changes, doSomeAnalytics() will now get called with whatever arguments the onChange() function receives. To add or remove the enhancement to the child element's onChange() function, all you have to do is add or remove the wrapper. To change the way analytics works for that particular input, you can change the doSomeAnalytics() callback.

Repackage it as a Composite

A composite component lets you treat multiple children as a single component, allowing you to abstract the inner workings away. Let's say there's some kind of special logic going on in your analytics, which you want to reuse. Making a composite out of the logic will allow you to set it and forget it, re-using the component with just a change in props:

const AnalyticsInput = ({ variant, ...props }) => {
  const analyticsCallback = { ... } // variant logic

  return (
    <AnalyticsDecorator callback={analyticsCallback}>
      <Input {...props} />
    </AnalyticsDecorator>
  );
};

Using the composite <AnalyticsInput /> component, you can now write it using the same API as your regular input, but with the enhancement of a variant prop to change how you handle your analytics

<AnalyticsInput
  name="myInput"
  onChange={doAthing}
  value={myValue}
  variant="specialAnalyticsVariant"
/>