Forms in React, part 1

June 25, 2018

Building forms in React is usually considered verbose, annoying or even hard. In this series we will examine how forms are built, what are some pain points and how to approach them. There is no silver bullet and approach taken will depend on the type of application you are building. Before using some of the libraries that attempt to simplify form development, it is better to understand the source of complexity.

Our first example is a form containing just a single input:

The form is implemented as a functional component:

const Form = () => (
  <form>
    <label htmlFor="text">Text:</label>
    <input type="text" name="text" />
    <button>Send</button>
  </form>
);

The code is straightforward, but not very useful.

We want to do something with the input which means that we need to access it. If we were using vanilla JS code, we could use document.getElementsByName('text). But since we are in React, let’s build a controlled form component.

We also need to switch to class component before introducing state.

class Form extends React.Component {
  state = { text: '' };

  render() {
    return (
      <form>
        <label htmlFor="text">Text:</label>
        <input
          type="text"
          name="text"
          value={this.state.text}
          onChange={this.textChange}
        />
        <button>Send</button>
      </form>
    );
  }

  textChange = e => this.setState({ text: e.target.value });
}

Just by introducing state and change tracking we added a lot of code:

  • State and default values
  • Using state value as the current value of the input
  • Handling text change

The handler textChange is not really generic. If we had more than one input, we would need to duplicate it. Instead of hardcoding the name of our backing property, we can use name for that. This will make our handler reusable.

handleValueChanged = e => {
  // [e.target.name] allows us to use the value as key
  this.setState({ [e.target.name]: e.target.value });
};

This generic handler doesn’t work if our input is checkbox. In that case we need to change the handler to use checked property instead:

handleCheckedChanged = e => {
  this.setState({ [e.target.name]: e.target.checked });
};

And in case we are using numeric input, we really want to parse input numbers:

handleNumericChanged = e => {
  this.setState({ [e.target.name]: parseInt(e.target.value) });
};

So the code for the slighly denser form:

Becomes:

export class Form extends PureComponent {
  state = { number: 1, text: 'initial', checked: true };

  render() {
    const { number, text, checked } = this.state;
    return (
      <form>
        <div>
          <label htmlFor="number">Number</label>
          <input
            type="number"
            name="number"
            value={number}
            onChange={this.handleNumericChanged}
          />
        </div>
        <div>
          <label htmlFor="text">Text</label>
          <input
            type="text"
            name="text"
            value={text}
            onChange={this.handleValueChanged}
          />
        </div>
        <div>
          <label htmlFor="checked">Checkbox</label>
          <input
            type="checkbox"
            name="checked"
            checked={checked}
            onChange={this.handleCheckedChanged}
          />
        </div>
      </form>
    );
  }

  handleValueChanged = e => {
    this.setState({ [e.target.name]: e.target.value });
  };

  handleCheckedChanged = e => {
    this.setState({ [e.target.name]: e.target.checked });
  };

  handleNumericChanged = e => {
    this.setState({ [e.target.name]: parseInt(e.target.value) });
  };
}

Phew, that grew rather quickly, and we only have three fields! The form isn’t really pretty, functional and is also missing validation, value coercion and other interesting things. Every new feature will add more code and more complexity.

Also, it is quite clear that the change handling methods are somewhat generic and might be reused.

Let’s refactor it into something more generic:

// Just the HTML for our form
const Form = ({ number, text, checked, onChange }) => (
  <form>
    <div>
      <label htmlFor="number">Number</label>
      <input type="number" name="number" value={number} onChange={onChange} />
    </div>
    <div>
      <label htmlFor="text">Text</label>
      <input type="text" name="text" value={text} onChange={onChange} />
    </div>
    <div>
      <label htmlFor="checked">Checkbox</label>
      <input
        type="checkbox"
        name="checked"
        checked={checked}
        onChange={onChange}
      />
    </div>
  </form>
);

// generic change handler
const handleChange = e => {
  switch (e.target.type) {
    case 'text':
      return { [e.target.name]: e.target.value };

    case 'checkbox':
      return { [e.target.name]: e.target.checked };

    case 'number':
      return { [e.target.name]: parseInt(e.target.value) };
  }

  return {};
};

// our container with actual state
class FormContainer extends PureComponent {
  state = { number: 1, text: 'initial', checked: true };

  render() {
    return (
      <Form {...this.state} onChange={e => this.setState(handleChange(e))} />
    );
  }
}

The state changing code is using convention for property names which is quite brittle. It doesn’t support other input types and it is hard to differentiate between integers and floating point numbers.

Those with prior React experience might notice that the container component is rather generic and can be turned into Higher Order Component but it is too early to do that since we don’t have all the features we want to support in our forms.

In the next post we will add validation to our form which will also add additional complexity.