- How I built an async form Validation library in ~100 lines of code with React Hooks
- Austin Malerba
- The Nuggets translation Project
- Permanent link to this article: github.com/xitu/gold-m…
- Translator: Jerry – FD
- Proofreader: Yoyoyohamapi, Xuyuey, Xiaonizi1994
Form validation is a tricky business. Once you get into the form implementation, you’ll see that there are a number of boundary scenarios to deal with. Fortunately, there are many formverification libraries that provide the necessary table metering and handler functions to implement a robust form. But I’m going to challenge myself by building a form validation library of less than 100 lines of code using the React Hooks API. While React Hooks are still experimental, this is proof that React Hooks implement form validation.
For the record, THE library I wrote is actually less than 100 lines of code. But this tutorial is about 200 lines of code because I need to explain how the library is used.
Most form library tutorials I’ve seen focus on three core topics: asynchronous validation, form linkage: validation of certain form items needs to be triggered when other form items change, and optimization of form validation efficiency. I hate the way tutorials fix scenarios and ignore other variables. Since things tend to backfire in real life scenarios, I try to cover as many of them as possible.
Our goals need to meet:
-
Synchronously validates individual form items, including dependent form items that change when the value of a form item changes
-
Asynchronous validation of individual form items, including dependent form items that follow changes in their value
-
Synchronize validation of all form entries before submitting the form
-
Verify all form entries asynchronously before submitting the form
-
Try asynchronous submission, and display the error message returned if the form submission fails
-
Provide developers with functions to validate forms at appropriate times, such as onBlur
-
Allows multiple validation of a single form entry
-
Disallow submission of forms that fail validation
-
Form error messages are displayed only when there is an error message change or an attempt is made to submit the form
We will cover these scenarios by implementing an account registration form with a username, password, and password double confirmation. Here is a simple interface, let’s build the library together.
const form = useForm({
onSubmit,
});
const usernameField = useField("username", form, {
defaultValue: "".validations: [
async formData => {
await timeout(2000);
return formData.username.length < 6 && "Username already exists"; }].fieldsToValidateOnChange: []});const passwordField = useField("password", form, {
defaultValue: "".validations: [
formData= >
formData.password.length < 6 && "Password must be at least 6 characters"].fieldsToValidateOnChange: ["password"."confirmPassword"]});const confirmPasswordField = useField("confirmPassword", form, {
defaultValue: "".validations: [
formData= >formData.password ! == formData.confirmPassword &&"Passwords do not match"].fieldsToValidateOnChange: ["password"."confirmPassword"]});// const { onSubmit, getFormData, addField, isValid, validateFields, submitted, submitting } = form
// const { name, value, onChange, errors, setErrors, pristine, validate, validating } = usernameField
Copy the code
It’s a very simple API, but it gives us a lot of flexibility. As you may be aware, this interface contains two functions with similar names, Validation and validate. Validation is defined as a function that takes the form data and the name of the form item as arguments, and returns an error message if validation fails, along with a false value. The validate function, on the other hand, executes all validation functions for the form entry and updates the list of errors for the form entry.
Most importantly, we need a skeleton to handle form value changes and form submission. Our first attempt doesn’t include any validation, it just handles the state of the form.
// Skip boilerplate code: imports, ReactDOM, etc.
export const useField = (name, form, { defaultValue } = {}) = > {
let [value, setValue] = useState(defaultValue);
let field = {
name,
value,
onChange: e= >{ setValue(e.target.value); }};// Register the form entry
form.addField(field);
return field;
};
export const useForm = ({ onSubmit }) = > {
let fields = [];
const getFormData = (a)= > {
// Get an object containing the original form data
return fields.reduce((formData, field) = > {
formData[field.name] = field.value;
return formData;
}, {});
};
return {
onSubmit: async e => {
e.preventDefault(); // Block default form submission
return onSubmit(getFormData());
},
addField: field= > fields.push(field),
getFormData
};
};
const Field = ({ label, name, value, onChange, ... other }) = > {
return( <FormControl className="field"> <InputLabel htmlFor={name}>{label}</InputLabel> <Input value={value} onChange={onChange} {... other} /> </FormControl> ); }; const App = props => { const form = useForm({ onSubmit: async formData => { window.alert("Account created!" ); }}); const usernameField = useField("username", form, { defaultValue: "" }); const passwordField = useField("password", form, { defaultValue: "" }); const confirmPasswordField = useField("confirmPassword", form, { defaultValue: "" }); return ( <div id="form-container"> <form onSubmit={form.onSubmit}> <Field {... usernameField} label="Username" /> <Field {... passwordField} label="Password" type="password" /> <Field {... confirmPasswordField} label="Confirm Password" type="password" /> <Button type="submit">Submit</Button> </form> </div> ); };Copy the code
There’s no code that’s too hard to understand. The value of the form is all we care about. Each form item registers itself on the form before its initialization ends. Our onChange function is also very simple. The most complex function here is getFormData, but even so, this is nothing compared to the abstract Reduce syntax. GetFormData iterates through all the form items and returns a Plain Object representing the value of the form. Finally, it’s worth mentioning that preventDefault needs to be called when the form is submitted to prevent the page from reloading.
Things are going well. Now let’s add the verification. When the value of a form item changes or a form is submitted, instead of specifying which form items need to be validated, we validate all form items.
export const useField = (
name,
form,
{ defaultValue, validations = [] } = {}
) => {
let [value, setValue] = useState(defaultValue);
let [errors, setErrors] = useState([]);
const validate = async() = > {let formData = form.getFormData();
let errorMessages = await Promise.all(
validations.map(validation= > validation(formData, name))
);
errorMessages = errorMessages.filter(errorMsg= >!!!!! errorMsg); setErrors(errorMessages);let fieldValid = errorMessages.length === 0;
return fieldValid;
};
useEffect(
(a)= > {
form.validateFields(); // Validates form entries when value changes
},
[value]
);
let field = {
name,
value,
errors,
validate,
setErrors,
onChange: e= >{ setValue(e.target.value); }};// Register the form entry
form.addField(field);
return field;
};
export const useForm = ({ onSubmit }) = > {
let fields = [];
const getFormData = (a)= > {
// Get an object containing the original form data
return fields.reduce((formData, field) = > {
formData[field.name] = field.value;
return formData;
}, {});
};
const validateFields = async() = > {let fieldsToValidate = fields;
let fieldsValid = await Promise.all(
fieldsToValidate.map(field= > field.validate())
);
let formValid = fieldsValid.every(isValid= > isValid === true);
return formValid;
};
return {
onSubmit: async e => {
e.preventDefault(); // Prevent the form from submitting default events
let formValid = await validateFields();
return onSubmit(getFormData(), formValid);
},
addField: field= > fields.push(field),
getFormData,
validateFields
};
};
constField = ({ label, name, value, onChange, errors, setErrors, validate, ... other }) => {letshowErrors = !! errors.length;return( <FormControl className="field" error={showErrors}> <InputLabel htmlFor={name}>{label}</InputLabel> <Input id={name} value={value} onChange={onChange} onBlur={validate} {... other} /> <FormHelperText component="div"> {showErrors && errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)} </FormHelperText> </FormControl> ); }; const App = props => { const form = useForm({ onSubmit: async formData => { window.alert("Account created!" ); }}); const usernameField = useField("username", form, { defaultValue: "", validations: [ async formData => { await timeout(2000); return formData.username.length < 6 && "Username already exists"; } ] }); const passwordField = useField("password", form, { defaultValue: "", validations: [ formData => formData.password.length < 6 && "Password must be at least 6 characters" ] }); const confirmPasswordField = useField("confirmPassword", form, { defaultValue: "", validations: [ formData => formData.password !== formData.confirmPassword && "Passwords do not match" ] }); return ( <div id="form-container"> <form onSubmit={form.onSubmit}> <Field {... usernameField} label="Username" /> <Field {... passwordField} label="Password" type="password" /> <Field {... confirmPasswordField} label="Confirm Password" type="password" /> <Button type="submit">Submit</Button> </form> </div> ); };Copy the code
The code above is an improved version that looks like it works when you glance at it, but it’s not nearly enough to deliver to users. This version loses many of the flags used to hide error messages that might appear at the wrong time. For example, the form validates and displays error messages before the user has finished modifying the input information.
Basically, we need some basic tag state to tell the UI that no error message is displayed if the user does not change the value of the form item. To go further, we need some additional tag states in addition to these basic ones.
We need a marked state to record that the user has attempted to submit the form, and a marked state to record that the form is being submitted or that the form item is being validated asynchronously. You may also be wondering why we call validateFields inside useEffect instead of onChange. We need useEffect because setValue happens asynchronously, and it neither returns a promise nor gives us a callback. Therefore, the only way to determine if setValue is complete is to useEffect to listen for changes in value.
Now let’s implement these so-called labeled states. Use them to refine the UI and details.
export const useField = (
name,
form,
{ defaultValue, validations = [], fieldsToValidateOnChange = [name] } = {}
) => {
let [value, setValue] = useState(defaultValue);
let [errors, setErrors] = useState([]);
let [pristine, setPristine] = useState(true);
let [validating, setValidating] = useState(false);
let validateCounter = useRef(0);
const validate = async() = > {let validateIteration = ++validateCounter.current;
setValidating(true);
let formData = form.getFormData();
let errorMessages = await Promise.all(
validations.map(validation= > validation(formData, name))
);
errorMessages = errorMessages.filter(errorMsg= >!!!!! errorMsg);if (validateIteration === validateCounter.current) {
// Last call
setErrors(errorMessages);
setValidating(false);
}
let fieldValid = errorMessages.length === 0;
return fieldValid;
};
useEffect(
(a)= > {
if (pristine) return; // Avoid the first validation after rendering
form.validateFields(fieldsToValidateOnChange);
},
[value]
);
let field = {
name,
value,
errors,
setErrors,
pristine,
onChange: e= > {
if (pristine) {
setPristine(false);
}
setValue(e.target.value);
},
validate,
validating
};
form.addField(field);
return field;
};
export const useForm = ({ onSubmit }) = > {
let [submitted, setSubmitted] = useState(false);
let [submitting, setSubmitting] = useState(false);
let fields = [];
const validateFields = async fieldNames => {
let fieldsToValidate;
if (fieldNames instanceof Array) {
fieldsToValidate = fields.filter(field= >
fieldNames.includes(field.name)
);
} else {
// If fieldNames defaults, all form entries are validated
fieldsToValidate = fields;
}
let fieldsValid = await Promise.all(
fieldsToValidate.map(field= > field.validate())
);
let formValid = fieldsValid.every(isValid= > isValid === true);
return formValid;
};
const getFormData = (a)= > {
return fields.reduce((formData, f) = > {
formData[f.name] = f.value;
return formData;
}, {});
};
return {
onSubmit: async e => {
e.preventDefault();
setSubmitting(true);
setSubmitted(true); // The user has submitted the form at least once
let formValid = await validateFields();
let returnVal = await onSubmit(getFormData(), formValid);
setSubmitting(false);
return returnVal;
},
isValid: (a)= > fields.every(f= > f.errors.length === 0),
addField: field= > fields.push(field),
getFormData,
validateFields,
submitted,
submitting
};
};
constField = ({ label, name, value, onChange, errors, setErrors, pristine, validating, validate, formSubmitted, ... other }) => {letshowErrors = (! pristine || formSubmitted) && !! errors.length;return( <FormControl className="field" error={showErrors}> <InputLabel htmlFor={name}>{label}</InputLabel> <Input id={name} value={value} onChange={onChange} onBlur={() => ! pristine && validate()} endAdornment={ <InputAdornment position="end"> {validating && <LoadingIcon className="rotate" />} </InputAdornment> } {... other} /> <FormHelperText component="div"> {showErrors && errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)} </FormHelperText> </FormControl> ); }; const App = props => { const form = useForm({ onSubmit: async (formData, valid) => { if (! valid) return; await timeout(2000); SetErrors (["Make a longer username"]); if (formdata.username. Length < 10) { } else {// Emulated server returns 201 window.alert(' form valid: ${valid}, form data: ${json.stringify (formData)} '); }}}); const usernameField = useField("username", form, { defaultValue: "", validations: [ async formData => { await timeout(2000); return formData.username.length < 6 && "Username already exists"; } ], fieldsToValidateOnChange: [] }); const passwordField = useField("password", form, { defaultValue: "", validations: [ formData => formData.password.length < 6 && "Password must be at least 6 characters" ], fieldsToValidateOnChange: ["password", "confirmPassword"] }); const confirmPasswordField = useField("confirmPassword", form, { defaultValue: "", validations: [ formData => formData.password !== formData.confirmPassword && "Passwords do not match" ], fieldsToValidateOnChange: ["password", "confirmPassword"] }); let requiredFields = [usernameField, passwordField, confirmPasswordField]; return ( <div id="form-container"> <form onSubmit={form.onSubmit}> <Field {... usernameField} formSubmitted={form.submitted} label="Username" /> <Field {... passwordField} formSubmitted={form.submitted} label="Password" type="password" /> <Field {... confirmPasswordField} formSubmitted={form.submitted} label="Confirm Password" type="password" /> <Button type="submit" disabled={ ! form.isValid() || form.submitting || requiredFields.some(f => f.pristine) } > {form.submitting ? "Submitting" : "Submit"} </Button> </form> </div> ); };Copy the code
For the last time, we added a lot of stuff. Forma-like, validating, submitted, and biogenic states. We also added fieldsToValidateOnChange, which is passed to validateFields to declare which form items need to be validated when the form’s value changes. We use these marker states at the UI level to control when to display error messages and load animations and disable the submit button.
You may have noticed a very special validateCounter. We need to keep track of how many times the validate function is called, because it may be called again before the current call completes. In this case, we should discard the result of the current call and only use the result of the last call to update the error status of the form item.
With everything in place, this is what we have.
- Codesandbox. IO/embed/x964k…
React Hooks provides a concise form validation solution. This is my first attempt at using this API. Despite its flaws, I still feel its power. This interface is a little strange, because it comes the way I like it. Despite these flaws, however, it is very powerful.
I think it is missing some features, such as a callback mechanism to indicate when useState has finished updating state, which is also a way to check for changes to prop in useEffect Hook.
Afterword.
To make this tutorial easy to follow, I have deliberately omitted checksum exception handling for some parameters. For example, I don’t verify that the form argument passed in is actually a Form object. It would be nice if I could explicitly validate its type and throw a detailed exception. In fact, I’ve already written that the code will report an error like this.
Cannot readThe property 'addField of undefinedCopy the code
Before you can publish this code as an NPM package, you also need proper parameter validation and exception error handling. As I said, if you want to dig deeper, I’ve implemented a more robust version with superstruct that includes parameter validation.
If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.