Decluttering React Form Logic
Tags: react, javascript, hooks, tutorial
Published: 2020-04-09 15:43:15

React gives you a lot of control over the display and processing of form data.  But this control comes at a price:  You also have to write more code to manage that control.  But this can be frustrating, because so much of a programmer's day is spent searching for a way to provide the same functionality with _less_ code.

I'm going to illustrate a technique you can use to reduce the repetitive logic around form inputs.  But first, let's look at some "typical" code you might see around controlled and uncontrolled components.

## Uncontrolled Components

Uncontrolled components "feel" the most like old-fashioned HTML elements.  We don't have to manually update the value inside the form field after the user types something.  But we still have to manually grab the value after every new entry if we want to reference that value somewhere else.  A super-simple uncontrolled example would look like this:

```javascript
export default function App() {
  const [youTyped, setYouTyped] = useState("");

  const onChange = event => setYouTyped(event.currentTarget.value);

  return (
    <>
      <TextField
        defaultValue={""}
        label={"Email:"}
        onChange={onChange}
        required={true}
        type={"email"}
        variant={"outlined"}
      />
      <div style={{ marginTop: 50 }}>You typed: {youTyped}</div>
    </>
  );
}
```

This works... pretty well.  The text field behaves like a "normal" HTML input field in that it will auto-update itself as the user types.  And with the `onChange()` function, we can grab each new value as the user types, allowing us to do further processing.  

But there are some definite drawbacks to this approach.  

First, assuming that we want to have an ongoing reference to the most-recently entered value, we need to always remember to add that `onChange()` event.  Otherwise, it becomes laborious to grab the value of the nested `<input>` field, in real-time, via old-skool methods like `inputProps` and `document.getElementById()`.  

Second, notice that we annotated the field as being of `type={'email'}`.  We also declared it as `required={true}`.  And yet, when we type in the field, or tab out of it, there is no validation indicated on the field itself to tell us whether the input is valid.  

The `<TextField>` component in Material UI provides a convenient means by which we can tell the component whether it should display in an error state.  But for that to work, we have to constantly _tell it_ whether to do so.  

That code would look something like this:

```javascript
export default function App() {
  const [showError, setShowError] = useState(false);
  const [youTyped, setYouTyped] = useState("");

  const onChange = event => {
    setShowError(!event.currentTarget.validity.valid);
    setYouTyped(event.currentTarget.value);
  };

  return (
    <>
      <TextField
        defaultValue={""}
        error={showError}
        label={"Email:"}
        onChange={onChange}
        required={true}
        type={"email"}
        variant={"outlined"}
      />
      <div style={{ marginTop: 50 }}>You typed: {youTyped}</div>
    </>
  );
}
```

The error state on `<TextField>` is now properly rendered.  Although we're already starting to add a good deal of state tracking just so we can know the status of a single text input.  But it gets worse.  

Imagine you have a Submit button.  And you want that button to be disabled until the user has entered valid input.  To ensure that functionality, the code might look something like this:

```javascript
export default function App() {
  const [isValid, setIsValid] = useState(false);
  const [showError, setShowError] = useState(false);
  const [youTyped, setYouTyped] = useState("");

  const onChange = event => {
    setIsValid(event.currentTarget.validity.valid);
    setShowError(!event.currentTarget.validity.valid);
    setYouTyped(event.currentTarget.value);
  };

  return (
    <>
      <TextField
        defaultValue={""}
        error={showError}
        label={"Email:"}
        onChange={onChange}
        required={true}
        type={"email"}
        variant={"outlined"}
      />
      <div style={{ marginTop: 50 }}>You typed: {youTyped}</div>
      <Button disabled={!isValid} style={{marginTop: 50}}>Submit</Button>
    </>
  );
}
```
You might be thinking that there's no need for the `isValid` state variable.  In theory, you could always set the `<Button>`'s `disabled` attribute to `!showError`.  The problem with this approach is that it doesn't properly account for the form's _initial state_.    

After the user begins typing in the Email field, the Submit button should always be _enabled_ if the Email field's `showError` state is `FALSE`, and _disabled_ if the Email field's `showError` state is `TRUE`.  But when the form first loads, we want the Submit button to be _disabled_, even though the Email field's `showError` state is `FALSE`, because we don't want the Email field to show an error before the user has had any chance to enter data.

## Controlled Components

The logic in the above example is quickly starting to become something of a mess.  We have one measly little `<TextField>`.  And yet, to properly display the `youTyped` value, _and_ to properly display the error/no-error state on the field, _and_ to properly control the disabled/enabled state of the Submit `<Button>`, our component is swiftly growing.  

We are tracking three separate state variables for a single `<TextField>` component.  And all three of those variables need to be updated with a custom `onChange()` method.  You can imagine how fast this logic can balloon if we have a form that has fields for, say, first name, last name, middle initial, street address 1, street address 2, city, state, and postal code.

What if we switch this to a _controlled_ component?  Does that make the logic any cleaner?  That would look something like this.

```javascript
export default function App() {
  const [emailField, setEmailField] = useState({
    isValid: false,
    showError: false,
    value: ""
  });

  const onChange = event => {
    setEmailField({
      isValid: event.currentTarget.validity.valid,
      showError: !event.currentTarget.validity.valid,
      value: event.currentTarget.value,
    });
  };

  return (
    <>
      <TextField
        error={emailField.showError}
        label={"Email:"}
        onChange={onChange}
        required={true}
        type={"email"}
        variant={"outlined"}
        value={emailField.value}
      />
      <div style={{ marginTop: 50 }}>You typed: {emailField.value}</div>
      <Button disabled={!emailField.isValid} style={{ marginTop: 50 }}>
        Submit
      </Button>
    </>
  );
}
```
This logic is certainly _a bit_ different.  Since we were tracking three separate values, all related to the state of the email field, I consolidated them into a single object.  And because we're now using a _controlled_ component instead of an _uncontrolled_ component, I removed the `defaultValue` attribute and replaced it with a `value` attribute.  

But is this really any "better"??  Umm...

We're still spawning _a lot_ of logic that's all tied to a single little `<TextField>` component.  This logic gets ever-uglier if we need to add more `<TextField>` components to the form.  There's gotta be a better way.

## A Dynamically-Updating Text Field

(You can see a live example of the following code here: https://stackblitz.com/edit/react-uncontrolled-text-field)

I'd been meaning to write a wrapper component for awhile that would help me solve this code bloat.  Here's an example of my "evolved" approach:

```javascript
// App
const getTextField = () => {
  return {
    isValid: false,
    showError: false,
    value: ""
  };
};

export default function App() {
  const [emailField, setEmailField] = useState(getTextField());

  return (
    <>
      <DynamicTextField
        error={emailField.showError}
        label={"Email:"}
        required={true}
        type={"email"}
        updateFieldFunction={setEmailField}
        variant={"outlined"}
        value={emailField.value}
      />
      <div style={{ marginTop: 50 }}>You typed: {emailField.value}</div>
      <Button disabled={!emailField.isValid} style={{ marginTop: 50 }}>
        Submit
      </Button>
    </>
  );
}

// DynamicTextField
export default function DynamicTextField(props) {
  const getRenderProps = () => {
    let renderProps = JSON.parse(JSON.stringify(props));
    delete renderProps.updateFieldFunction;
    return renderProps;
  };

  const onChange = (event = {}) => {
    const {currentTarget} = event;
    props.updateFieldFunction({
      isValid: currentTarget.validity.valid,
      showError: !currentTarget.validity.valid,
      value: currentTarget.value,
    });
    if (props.onChange)
      props.onChange(event);
  };

  return <TextField {...getRenderProps()} onChange={onChange} />;
}
```

Notice that in `<App>`, _there is no `onChange()` function_.  And yet the values associated with the text field are available in `<App>`, in real time, as the user enters data.  This is possible because we're using a standard "shape" for the data object associated with the text field, and we're passing the state-updating function to `<DynamicTextField>`.  This allows `<DynamicTextField>` to update the values in the parent component.

`<DynamicTextField>` has its own `onChange()` function.  This is used to auto-update the field values.  But this doesn't stop the parent component from supplying its own `onChange()` function if it has _additional processing_ that should be done.  But if the only need for `onChange()` is to update the stateful values associated with the field, then there's no reason for the parent component to supply its own `onChange()` function.

By wrapping the `<TextField>` component, I also have the ability to provide additional validations without having to rewrite that code every place where I want to use them.   

For example, my live implementation of `<DynamicTextField>` looks closer to this:

```javascript
export default function DynamicTextField(props) {
  const getRenderProps = () => {
    let renderProps = JSON.parse(JSON.stringify(props));
    delete renderProps.allowLeadingSpaces;
    delete renderProps.allowSpaces;
    delete renderProps.updateFieldFunction;
    return renderProps;
  };

  const getValue = (currentTarget = {}) => {
    let value = currentTarget.value;
    if (!props.allowSpaces)
      value = value.replace(/ /g, '');
    else if (!props.allowLeadingSpaces)
      value = value.trimStart();
    return value;
  };

  const onChange = (event = {}) => {
    const {currentTarget} = event;
    props.updateFieldFunction({
      isValid: currentTarget.validity.valid,
      showError: !currentTarget.validity.valid,
      value: getValue(currentTarget),
    });
    if (props.onChange)
      props.onChange(event);
  };

  return <TextField {...getRenderProps()} onChange={onChange} />;
}
```

Since I now have a common `onChange()` function that's applied for every instance of `<DynamicTextField>`, I can add things like auto-trimming.  Specifically, I allow two props related to trimming:

1. `allowSpaces` is set by default to `TRUE`.  But if the prop is set to `FALSE`, all spaces are automatically stripped from the user-supplied input.  This is particularly useful for data like email fields, where there is no valid use-case for a space in the data.

1. `allowLeadingSpaces` is set by default to `FALSE`.  Usually, when we're asking the user for input (e.g., first name, street address, city, tell-us-something-about-yourself, whatever...) there is no valid use-case to have leading spaces in this input.  So this auto-trims the beginning of the user-supplied data, unless it's set to `TRUE`.

This has allowed me to remove a great many `.trim()` calls from my code.  Because now, when the `<DynamicTextField>` is updated, I already know that it's free of invalid surrounding spaces.

In my personal implementation, I also have a check that ensures fields of `type={'email'}` end in a top-level domain - because HTML's "email" input type will pass an email string as "valid" even if it doesn't end with a top-level domain.

## Streamlining Code

This approach allowed me to remove a _large volume_ of code from my app.  I had sooo many components where there were text fields.  And on every one of those components, for every one of those text fields, I had a dedicated `onChange()` function that did nothing but ensure that the latest user input made it back into the state variable.  Now... that's all gone.

This also allows you to centralize any custom validations that you might be using throughout your app. I already talked about auto-trimming and checking for top-level domains.  But you could certainly put _other_ useful validations in the `<DynamicTextField>` component.