Created
February 21, 2019 14:30
-
-
Save makarovas/93776f9519c1b6f6f02356a1866f8179 to your computer and use it in GitHub Desktop.
Async form validation with react hooks
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://codesandbox.io/s/x964kxp2vo?from-embed | |
import React, { Component, useState, useEffect, useRef } from "react"; | |
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) { | |
// this is the most recent invocation | |
setErrors(errorMessages); | |
setValidating(false); | |
} | |
let fieldValid = errorMessages.length === 0; | |
return fieldValid; | |
}; | |
useEffect( | |
() => { | |
if (pristine) return; // Avoid validate on mount | |
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 not provided, validate all fields | |
fieldsToValidate = fields; | |
} | |
let fieldsValid = await Promise.all( | |
fieldsToValidate.map(field => field.validate()) | |
); | |
let formValid = fieldsValid.every(isValid => isValid === true); | |
return formValid; | |
}; | |
const getFormData = () => { | |
return fields.reduce((formData, f) => { | |
formData[f.name] = f.value; | |
return formData; | |
}, {}); | |
}; | |
return { | |
onSubmit: async e => { | |
e.preventDefault(); | |
setSubmitting(true); | |
setSubmitted(true); // User has attempted to submit form at least once | |
let formValid = await validateFields(); | |
let returnVal = await onSubmit(getFormData(), formValid); | |
setSubmitting(false); | |
return returnVal; | |
}, | |
isValid: () => fields.every(f => f.errors.length === 0), | |
addField: field => fields.push(field), | |
getFormData, | |
validateFields, | |
submitted, | |
submitting | |
}; | |
}; | |
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( | |
() => { | |
form.validateFields(); // Validate fields when value changes | |
}, | |
[value] | |
); | |
let field = { | |
name, | |
value, | |
errors, | |
validate, | |
setErrors, | |
onChange: e => { | |
setValue(e.target.value); | |
} | |
}; | |
// Register field with the form | |
form.addField(field); | |
return field; | |
}; | |
export const useForm = ({ onSubmit }) => { | |
let fields = []; | |
const getFormData = () => { | |
// Get an object containing raw 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 default form submission | |
let formValid = await validateFields(); | |
return onSubmit(getFormData(), formValid); | |
}, | |
addField: field => fields.push(field), | |
getFormData, | |
validateFields | |
}; | |
}; | |
const Field = ({ | |
label, | |
name, | |
value, | |
onChange, | |
errors, | |
setErrors, | |
validate, | |
...other | |
}) => { | |
let showErrors = !!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> | |
); | |
}; | |
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) { | |
// this is the most recent invocation | |
setErrors(errorMessages); | |
setValidating(false); | |
} | |
let fieldValid = errorMessages.length === 0; | |
return fieldValid; | |
}; | |
useEffect( | |
() => { | |
if (pristine) return; // Avoid validate on mount | |
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 not provided, validate all fields | |
fieldsToValidate = fields; | |
} | |
let fieldsValid = await Promise.all( | |
fieldsToValidate.map(field => field.validate()) | |
); | |
let formValid = fieldsValid.every(isValid => isValid === true); | |
return formValid; | |
}; | |
const getFormData = () => { | |
return fields.reduce((formData, f) => { | |
formData[f.name] = f.value; | |
return formData; | |
}, {}); | |
}; | |
return { | |
onSubmit: async e => { | |
e.preventDefault(); | |
setSubmitting(true); | |
setSubmitted(true); // User has attempted to submit form at least once | |
let formValid = await validateFields(); | |
let returnVal = await onSubmit(getFormData(), formValid); | |
setSubmitting(false); | |
return returnVal; | |
}, | |
isValid: () => fields.every(f => f.errors.length === 0), | |
addField: field => fields.push(field), | |
getFormData, | |
validateFields, | |
submitted, | |
submitting | |
}; | |
}; | |
const Field = ({ | |
label, | |
name, | |
value, | |
onChange, | |
errors, | |
setErrors, | |
pristine, | |
validating, | |
validate, | |
formSubmitted, | |
...other | |
}) => { | |
let showErrors = (!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); // Simulate network time | |
if (formData.username.length < 10) { | |
//Simulate 400 response from server | |
usernameField.setErrors(["Make a longer username"]); | |
} else { | |
//Simulate 201 response from server | |
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> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment