Decluttering React Form Logic
Adam Nathaniel Davis
Posted on April 9, 2020
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:
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:
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:
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.
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:
// 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:
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:
allowSpaces
is set by default toTRUE
. But if the prop is set toFALSE
, 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.allowLeadingSpaces
is set by default toFALSE
. 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 toTRUE
.
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.
Posted on April 9, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.