An essential part of our work as developers is to build forms, that allow our end users to provide input to our applications. There are multiple approaches on how to build and manage forms with React and in this tutorial I want to present my preferable way.
Multiple times I’ve seen forms where developers create a new useState
variable for each form field, which quickly becomes a nightmare when the forms scales and the component ends up with 20+ state variables. There are some great libraries like React Hook Form but in some cases having 3rd party solution is not necessary. Let’s build a form from scratch and control the state using the built-in useReducer
hook.
Link to Git repo with the code example
Initial setup
Here is the simple form that we will create by the end of this tutorial. This approach is more appropriate for larger forms, but I want to keep this tutorial simple and precise and I hope that you could apply this approach for more complex forms.
First, we will create a React app and optionally install Bootstrap
. Feel free to skip installing Bootstrap
, I use it to be able to focus entirely on the logic and not on the styling.
npx create-react-app scalable-react-form
npm install -S bootstrap
I will add the form to App.js
file for the sake of simplicity. Here is the markup for the form:
import "bootstrap/dist/css/bootstrap.css";
export default function App() {
return (
<div className="d-flex mt-5 justify-content-center">
<div className="card w-50">
<div className="card-body">
<form>
<div className="mb-3">
<label htmlFor="exampleInputEmail1" className="form-label">
Email address
</label>
<input
type="email"
className="form-control"
id="exampleInputEmail1"
/>
</div>
<div className="mb-3">
<label htmlFor="exampleInputPassword1" className="form-label">
Password
</label>
<input
type="password"
className="form-control"
id="exampleInputPassword1"
/>
</div>
<div className="mb-3 form-check">
<input
type="checkbox"
className="form-check-input"
id="exampleCheck1"
/>
<label className="form-check-label" htmlFor="exampleCheck1">
Subscribe to emails
</label>
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
<button type="submit" className="btn btn-outline-primary ms-3">
Reset form
</button>
</form>
</div>
</div>
</div>
);
}
Set up the reducer
In my practice, I’ve seen dozens of examples of form handled with local component state using useState
. While this is fine for very simple forms, this can quickly turn into state nightmare with so many variables, that one can easily lose track. In such cases, it’s best to use the built-in useReducer
hook, which allows us to extract the form logic into a separated file and keep the component clean and avoid bloating it. Each form field will be a new object which has 1 required property(value
) and 2 optional properties(isValid
and isTouched
) for fields that need validation. Let’s create a new file reducer/registerFormReducer.js
. Here is the content, I will add explanations below the code:
import { validateEmail, validateLength } from "../utils/validation.util";
export const initialState = {
email: {
value: "",
isValid: false,
isTouched: false,
},
password: {
value: "",
isValid: false,
isTouched: null,
},
shouldSubscribe: {
value: false,
isValid: true,
isTouched: null,
},
};
export const actionTypes = {
UPDATE_EMAIL: "UPDATE_EMAIL",
UPDATE_PASSWORD: "UPDATE_PASSWORD",
UPDATE_SHOULD_SUBSCRIBE: "UPDATE_SHOULD_SUBSCRIBE",
RESET_FORM: "RESET_FORM",
};
export const registerFormReducer = (state = initialState, action) => {
const { payload } = action;
switch (action.type) {
case actionTypes.UPDATE_EMAIL: {
const isValid = validateLength(payload, 8) && validateEmail(payload);
let { isTouched } = state.email;
// Set isTouched to true once the characters count exceed 3
if (!state.email.isTouched) {
if (state.email.value.length >= 3) {
isTouched = true;
}
}
return {
...state,
email: {
value: action.payload,
isTouched,
isValid,
},
};
}
case actionTypes.UPDATE_PASSWORD: {
const isValid = validateLength(payload, 5);
let { isTouched } = state.email;
// Set isTouched to true once the characters count exceed 3
if (!state.password.isTouched) {
if (state.password.value.length >= 3) {
isTouched = true;
}
}
return {
...state,
password: {
value: payload,
isTouched,
isValid,
},
};
}
case actionTypes.UPDATE_SHOULD_SUBSCRIBE: {
return {
...state,
shouldSubscribe: {
value: action.payload,
},
};
}
case actionTypes.RESET_FORM:
return initialState;
}
throw Error("Unknown action: " + action.type);
};
As you see in the first line, we import utils/validation.util.js
. It contains 2 very simple utility functions for validation :
export const validateLength = (value, minValue) => {
return value.length >= minValue;
};
export const validateEmail = (value) => {
const regex = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
return regex.test(value);
};
So, first we declare the form’s initial state. If we didn’t have validation, the state could have been simpler, with each form field having just a single string value, instead of an object.
Below initialState
, we define the constants for the reducer actions. Feel free to extract it to a separated file.
Below is the registerFormReducer
function – if you have experience with Redux, this may look familiar to you. We have 3 actions for each form field, which is triggered on every key stroke(see next section). For more complex forms, you could have a generic UPDATE_FIELD
action which would take a field key and a value from the payload.
The validation is performed inside the reducer. For email, there are 2 validation rules – valid email and length equal to or more than 8. For password, we simply check for length. Of course, you can add more complex validation using the same principle. The “Subscribe” checkbox doesn’t need any validation, so it’s an object with only a value
property. Last, we have the RESET_FORM
action, which simply resets the form state back to initialState
.
Link the state and the form
Now that the reducer has been created, we need to link it with the existing form. Here is the code, you will find explanations below:
import "bootstrap/dist/css/bootstrap.css";
import {
registerFormReducer,
initialState,
actionTypes,
} from "./reducer/registerFormReducer";
import { useReducer } from "react";
export default function App() {
const [formState, dispatch] = useReducer(registerFormReducer, initialState);
const handleEmailChange = (value) =>
dispatch({ type: actionTypes.UPDATE_EMAIL, payload: value });
const handlePasswordChange = (value) =>
dispatch({ type: actionTypes.UPDATE_PASSWORD, payload: value });
const handleShouldSubscribeChange = () =>
dispatch({
type: actionTypes.UPDATE_SHOULD_SUBSCRIBE,
payload: !formState.shouldSubscribe.value,
});
const handleFormReset = (e) => {
e.preventDefault();
dispatch({ type: actionTypes.RESET_FORM });
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(formState);
};
return (
<div className="d-flex mt-5 justify-content-center">
<div className="card w-50">
<div className="card-body">
<form>
<div className="has-validation mb-3">
<label htmlFor="exampleInputEmail1" className="form-label">
Email address
</label>
<input
type="email"
className="form-control"
id="exampleInputEmail1"
value={formState.email.value}
onChange={(e) => handleEmailChange(e.target.value)}
/>
<div className="invalid-feedback">
Make sure that you've entered a valid email and is at least 8
characters long.
</div>
</div>
<div className="has-validation mb-3">
<label htmlFor="exampleInputPassword1" className="form-label">
Password
</label>
<input
type="password"
className="form-control"
id="exampleInputPassword1"
value={formState.password.value}
onChange={(e) => handlePasswordChange(e.target.value)}
/>
<div className="invalid-feedback">
The password should contain at least 5 characters.
</div>
</div>
<div className="mb-3 form-check">
<input
type="checkbox"
className="form-check-input"
id="exampleCheck1"
onChange={() => handleShouldSubscribeChange()}
checked={formState.shouldSubscribe.value}
/>
<label className="form-check-label" htmlFor="exampleCheck1">
Subscribe to emails
</label>
</div>
<button
className="btn btn-primary"
onClick={(e) => handleSubmit(e)}
>
Submit
</button>
<button
className="btn btn-outline-primary ms-3"
onClick={(e) => handleFormReset(e)}
>
Reset form
</button>
</form>
</div>
</div>
</div>
);
}
So, we can retreive the whole form state via the useReducer
hook. Calling the hook returns 2 values – the state and a dispatch function.
const [formState, dispatch] = useReducer(registerFormReducer, initialState);
In a real project, I’d declare the action handlers in a separate file and import them in App.js
, but for the sake of simplicity, I’ve declared them in the component. Notice how easy it is to reset the whole form – if we were to use useState
we had to change the state for each controlled variable.
const handleEmailChange = (value) =>
dispatch({ type: actionTypes.UPDATE_EMAIL, payload: value });
const handlePasswordChange = (value) =>
dispatch({ type: actionTypes.UPDATE_PASSWORD, payload: value });
const handleShouldSubscribeChange = () =>
dispatch({
type: actionTypes.UPDATE_SHOULD_SUBSCRIBE,
payload: !formState.shouldSubscribe.value,
});
const handleFormReset = (e) => {
e.preventDefault();
dispatch({ type: actionTypes.RESET_FORM });
};
Implement inline validation
I will use the validation classes provided by Bootstrap to improve the visual feedback. Let’s first create a function inside the component which will add the correct validation class depending on whether the value is valid or not:
const addClassNameForValidity = (propertyName) => {
if (formState[propertyName].isTouched && !formState[propertyName].isValid) {
return "is-invalid"; // Add "is-invalid" class only if the field is touched and is NOT valid
} else {
if (
formState[propertyName].isTouched &&
formState[propertyName].isValid
) {
return "is-valid"; // Add "is-valid" class only if the field is touched and IS valid
}
return "";
}
};
..and here is how we add the class:
<input
type="email"
className={`form-control ${addClassNameForValidity("email")}`}
id="exampleInputEmail1"
value={formState.email.value}
onChange={(e) => handleEmailChange(e.target.value)}
/>
We can do the same for the password field as well, but we need to call the function with "password"
argument instead. Here is the result:
Final words
At first glance, this solution may look like overkill for such a simple form, but if you’re working on a more complex form that needs to scale, this approach provides you with more flexibility and more readable code. As an exercise, you can add a “Repeat password” field and add a new validation utility function for ensuring that the passwords match.
Link to Git repo with the code example