selbekk

Creating Flexible Components

March 18, 2019
6 min read

A bunch of tips and tricks to how you can make your components more flexible, reusable and accessible by default.

Creating reusable components are hard. Figuring out the API is a pain, deciding on features isn't easy and just churning out the code isn't for the faint hearted either.

This blog post is going to take you through how we can create a reusable input group, which makes it easy to compose together form fields. I'll explain what an input group is, what we want it to do, and implement it step by step. When we're done, you'll be able to create your own - and hopefully feel more secure about writing components going forward.

Please note that the component we're creating just works as an example. You might not need this particular component in your app, but you'll definitely use some of these techniques in the future.

So what's an input group?

I don't think you'll find a text book definition anywhere, but to me, an input group is a component that shows some kind of form input, creates a related label, shows an error message if there is one, and handles as much accessibility issues as possible without the user having to think about it. Basically everything a form input needs, except for itself.

An input group is a component
Kristofer Selbekk

That's what we're going to create today - step by step, and piece by piece. So let's get to it!

Step 1: Show some kind of form input

To get started, let's just create a component called InputGroup that renders whatever children we pass it:

function InputGroup(props) {
  return <div>{props.children}</div>;
}

This way, we can pass in whatever form input we want to our component:

<InputGroup>
  <input />
</InputGroup>

Okay, that wasn't too hard, was it? Still with me?

Step 2: Create a related label!

We want to be able to pass in a label text to our component. Let's create a label prop:

function InputGroup(props) {
  return (
    <div>
      <label>{props.label}</label> {props.children}
    </div>
  );
}

Now, we want to make sure the label is attached to the input somehow. We use the htmlFor attribute for this. We accept the ID as a prop, and then we apply it to the label:

function InputGroup(props) {
  return (
    <div>
      <label htmlFor={props.id}>{props.label}</label> {props.children}
    </div>
  );
}

This is kind of annoying though - we need to pass in the ID both to our InputGroup and our input. Let's apply it to the input automatically:

function InputGroup(props) {
  return (
    <div>
      <label htmlFor={props.id}> {props.label} </label>
      {React.Children.map(props.children, child =>
        React.cloneElement(child, { id: props.id })
      )}
    </div>
  );
}

What, React.Children? React.cloneElement? These APIs are seldom used, but they are pretty powerful. Basically what happens is: for every child passed into this component, create a copy of it and add an additional id prop.

React.Children.map lets us map over children like they were an array. See the docs for more details.

React.cloneElement creates a copy of a React element, and lets us override any props passed in with our own version. See the docs for more details.

With this in place, we can pass in our ID once, and have an accessible label for our form label.

<InputGroup id="first-name" label="First name">
  <input />
</InputGroup>

Bonus: Skip the ID altogether

Chances are, you don't really care about the ID. The fact that we need one here, is an implementation detail of the way labels and inputs work. Wouldn't it be nice if we could skip passing it in altogether?

Turns out, that's very possible. We can use a random string generator to create an ID for us, and use that for an ID if one isn't provided.

import uuid from "uuid/v4";
function InputGroup(props) {
  const id = React.useMemo(() => props.id || "input-" + uuid(), [props.id]);
  return (
    <div>
      <label htmlFor={id}> {props.label} </label>
      {React.Children.map(props.children, child =>
        React.cloneElement(child, { id })
      )}
    </div>
  );
}

Here, we use the React.useMemo hook to avoid creating a new ID on every render. We pass in the props.id to its dependency array, which makes sure we only re-create the ID if the id prop changes for some reason.

Also note that we let the consumer set her own ID if that's needed for some reason. This is an important principle of component API design:

Allow the consumer to override any auto-generated values!

Step 3: Add error handling

Most forms implement some kind of validation. There are tons of great validation libraries out there (I even created my own - calidation!), a choice left to the reader. What we're going to add is a way to show validation errors in our InputGroup component.

We start off by adding an error prop, and rendering it below our children:

function InputGroup(props) {
  const id = React.useMemo(() => props.id || "input-" + uuid(), [props.id]);
  return (
    <div>
      <label htmlFor={id}> {props.label} </label>
      {React.Children.map(props.children, child =>
        React.cloneElement(child, { id })
      )}
      {props.error && <div>{props.error}</div>}
    </div>
  );
}

This is pretty straight forward, but let's step our game up just a tiny bit. To help screen readers and other assistive technologies, we should mark our input field as invalid. We can do that by setting the aria-invalid prop on our form input:

function InputGroup(props) {
  const id = React.useMemo(() => props.id || "input-" + uuid(), [props.id]);
  const isInvalid = props["aria-invalid"] || String(!!props.error);
  return (
    <div>
      <label htmlFor={id}> {props.label} </label>
      {React.Children.map(props.children, child =>
        React.cloneElement(child, { id, "aria-invalid": isInvalid })
      )}
      {props.error && <div>{props.error}</div>}
    </div>
  );
}

Here, we set the aria-invalid prop of our form input to "true" if a non-falsy error prop is passed, and "false" if the error is blank or undefined, for example. Note that we're coercing this prop into a string, as this is what the DOM expects.

Finally, we also let the consumers override this value by passing in aria-invalid themselves.

Step 4: Make it flexible 🧘‍♂️

By now, we've created a pretty solid input group component. It takes care of accessibility concerns, it shows an input label and an error, and it lets us pass in whatever input we want. Still, there's work to do.

Since we don't know how people will use our component, we might want to let people override a few things. The label and error elements might need to be switched out in some cases, or rendered slightly differently. Seems like something we can do!

function InputGroup(props) {
  const id = React.useMemo(() => props.id || "input-" + uuid(), [props.id]);
  const isInvalid = props["aria-invalid"] || String(!!props.error);
  const label =
    typeof props.label === "string" ? (
      <label htmlFor={id}>{props.label}</label>
    ) : (
      React.cloneElement(props.label, { htmlFor: id })
    );
  const error =
    typeof props.error === "string" ? <div>{props.error}</div> : props.error;
  return (
    <div>
      {label}
      {React.Children.map(props.children, child =>
        React.cloneElement(child, { id, "aria-invalid": isInvalid })
      )}
      {props.error && error}
    </div>
  );
}

The API we've implemented above lets us either pass in a string or some JSX to our label and error props. If we're passing a string value, the default UI is rendered, but if we're passing some JSX, we let the consumer decide how it's going to look. Usage might look like this:

<InputGroup
  label={<MyCustomLabelComponent>First name</MyCustomLabelComponent>}
  error="some error occurred"
>
  <input />
</InputGroup>

Allowing this kind of customization makes your component flexible enough for most use cases, while maintaining a small and predictable API.

Step 5: Make it even more flexible

There's one last assumption this component makes, that I'd love to get rid of. That assumption is that we'll only ever pass in a single child, and that that child is a form input. We might want to have several inputs, or some text surrounding the input, or just have some custom UI that needs to be rendered. Let's fix that.

function InputGroup(props) {
  const id = React.useMemo(() => props.id || "input-" + uuid(), [props.id]);
  const isInvalid = props["aria-invalid"] || String(!!props.error);
  const label =
    typeof props.label === "string" ? (
      <label htmlFor={id}>{props.label}</label>
    ) : (
      React.cloneElement(props.label, { htmlFor: id })
    );
  const error =
    typeof props.error === "string" ? <div>{props.error}</div> : props.error;
  return (
    <div>
      {label} {props.children({ id, "aria-invalid": isInvalid })}
      {props.error && error}
    </div>
  );
}

Notice we're now calling props.children with our augmented props. This is called the "render props" pattern, which you can read more about in the docs. This leads to usage like this:

<InputGroup label="Amount">
  {inputProps => (
    <div>
      $ <input {...inputProps} />
    </div>
  )}
</InputGroup>

This way, we have full control over how our UI is rendered. We're providing the props meant for the form input as the argument to our children-function, and lets the consumer place them on the correct element.

This approach has its downsides though - the syntax looks terrible, and the consumer needs to spread the props manually. Consider if this is a good pattern for your project.

Summary

Creating a good, solid API for a React component is no easy task. The component should be reusable by different consumers, everything should be accessible by default, and anything your component does should be overridable.

This article has gone through a few ways to "get there". It sure complicates things a bit up front, but it lets you create incredibly flexible UIs without having to add new props to your component every week.

If you want to play with this API, you can do so in this CodeSandbox:

Thanks for reading!

All rights reserved © 2024