Things about software engineering

React Component Composition Without Headaches

July 19, 2020

Something I see a lot of people obsess over in React is component reusability. It’s tremendous on paper – write a component and get to use it throughout your app. However, in practice, I find it to be more nuanced.

The key point to make is that it’s all too easy to make a frankenstein component that winds up with an insane number of props, all for the sake of reusability, with little concern for maintainability.

A Simple Input

Here’s an intentionally simple input component that’ll work for the purpose of demonstration:

interface InputProps {
  name: string;
  id: string;
  type: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

export const Input = (props: InputProps) => (
  <input
    name={props.name}
    id={props.id}
    type={props.type}
    value={props.value}
    onChange={props.onChange}
  />
);

A more real world version would have styles and/or class names and so on.

Extending the Input

So you’ve rolled the above component through your app, and it’s happy days until the UX designer comes and tells you that you need to handle password autocomplete better. “No problem” you say, as you get back on the tools and wind up with something like this:

interface InputProps {
  name: string;
  id: string;
  type: string;
  value: string;
  onChange: (React.ChangeEvent<HTMLInputElement>: e) => void;
  valid: boolean;
  autocomplete?: string;
}

export const Input = (props: InputProps) => (
  <input
    name={props.name}
    id={props.id}
    type={props.type}
    value={props.value}
    onChange={props.onChange}
    style={{ border: props.valid ? '1px solid green' : '1px solid black' }}
    autocomplete={props.autocomplete ?? 'off'}
  />
);

You’ve made the autocomplete prop optional and default it to off so as to not break existing uses of the component, and you’ve gone the extra mile to change the appearance if the input is valid.

Where it Breaks

Another form is required in the app, and you or someone else, charged with making it, copies and pastes some code. Now you have an email input with the wrong autocomplete property for the given input, not to mention strings everywhere.

const SomeForm = () => {
  const [password, setPassword] = useState('');
  const [email, setEmail] = useState('');
  return (
    <form>
      <Input
        name="email"
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        autocomplete="new-password"
      >
      <Input
        name="password"
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        autocomplete="new-password"
      >
    </form>
  );
};

Not a show stopper but far from ideal — fortunately it’s picked up quickly. You make the following changes to your component to prevent the same happening again:

const pwAutoTypes = ['off', 'on', 'current-password', 'new-password'];

export const Input = (props: InputProps) => {
  const { type, autocomplete } = props;

  if (type === 'password' && !pwAutoTypes.includes(autocomplete ?? 'off')) {
    throw new Error('Invalid autocomplete for password input type');
  }

  return (
    <input
      name={props.name}
      id={props.id}
      type={props.type}
      value={props.value}
      onChange={props.onChange}
      style={{ border: props.valid ? '1px solid green' : '1px solid black' }}
      autocomplete={props.autocomplete ?? 'off'}
    />
  );
};

Although this solves one problem, you start thinking about other commonly used input types, and wonder if you’ll need to have similar logic for them. Do you want to create and maintain that? Should you even need to?

A Better Solution

To me, a better solution is to have a component for each common input, and for the less often used you can just set values inline as required. Also, if you haven’t figured it out already we are ignoring styling as, ostensibly, you’ll have a base style defined elsewhere or your own method of applying them.

interface Props {
  name: string;
  id: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  autocomplete: 'on' | 'off' | 'new-password' | 'current-password';
}

export const PasswordInput = ({
  name,
  id,
  value,
  onChange,
  autocomplete,
}: Props) => (
  <input
    name={name}
    id={id}
    type="password"
    value={value}
    onChange={onChange}
    autocomplete={autocomplete}
  />
);

With this, we have a component that’s easier to reason about and use, with far less room for error, and will write code that’s easier for you and the next person to read.

In Summary

Rather than making a one size fits all component, make a component that fits a common use case. You’ll be a far more productive — and happier — developer. Naturally whatever you’re doing is going to vary app to app, team to team, project to project, but this general approach will work in almost any situation.

Further, you don’t need to do this up front. Sometimes it makes sense to do so, with form elements, buttons, layout-related-components and so on. In many other situations the case for a component or other abstraction won’t be immediately apparent, but will over time.