Last active
December 20, 2021 11:21
-
-
Save loburets/ebbdb302ae01ef2a854a4d21ea2de608 to your computer and use it in GitHub Desktop.
Real-world example of formik page with some complicated (but still pretty common for web forms) UI behaviour. See the first comment for details.
This file contains hidden or 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
import React, { Component, useEffect, useRef, useState } from 'react' | |
import { withRouter } from 'next/router' | |
import { Formik, Form, Field, useFormikContext } from 'formik' | |
import * as Yup from 'yup' | |
import Select from 'react-select' | |
import Moment from 'moment' | |
import { extendMoment } from 'moment-range' | |
import { withStyles } from '@material-ui/core/styles' | |
import { DATE_API_FORMAT } from 'consts/dates' | |
import Input from '@material-ui/core/Input' | |
import InputLabel from '@material-ui/core/InputLabel' | |
import FormHelperText from '@material-ui/core/FormHelperText' | |
import FormControl from '@material-ui/core/FormControl' | |
import { getUrl } from 'utils/api' | |
import { getYearsRange } from 'utils/dates' | |
import styles from 'components/AboutYouForm/styles' | |
import Button from 'components/Material/Buttons' | |
// yup validation example which we can use for some basic validations without custom business logic: | |
const yupSchema = Yup.object().shape({ | |
email: Yup.string() | |
.email('Invalid email') | |
.required('Required'), | |
firstName: Yup.string() | |
.min(3, 'Too Short!') | |
.max(50, 'Too Long!') | |
.required('Required'), | |
lastName: Yup.string() | |
.max(6, 'Too Long!') | |
.required('Required'), | |
}) | |
// validation example for field which is computed by separate inputs: | |
const validateBirthdateBySeparateInputsAsTheSingleField = (values, errors) => { | |
const { | |
birthdateYear, | |
birthdateMonth, | |
} = values | |
if (!birthdateYear || !birthdateMonth) { | |
return | |
} | |
const fullDate = moment([birthdateYear, birthdateMonth - 1, 1]).format("YYYY-MM-DD") | |
const birthday = new Date(fullDate) | |
const userAge = ~~((Date.now() - birthday) / (31557600000)) | |
if (userAge < 18) { | |
errors.birthdate = 'Must be at least 18 years of age' | |
} | |
} | |
// function which emulate api using for validation | |
const validateFirstNameUsingApi = async (value) => { | |
console.log('It makes api call to validate', {value}) | |
let error | |
// your api request can be here | |
await sleep(1500) | |
if (value && (value.includes('a') || value.includes('b') || value.includes('c'))) { | |
error = 'Api validation error: don\'t use letters a, b and c' | |
} | |
return error | |
} | |
// placeholder for async examples: | |
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) | |
// just stuff to get date range, ignore this part: | |
const moment = extendMoment(Moment) | |
const start = moment().subtract(10, 'years') | |
const end = moment().subtract(100, 'years') | |
const years = getYearsRange(start, end) | |
const optionsYear = years.map((m) => ({ value: m, label: m})) | |
const optionsMonthData = moment.months() | |
const optionsMonth = optionsMonthData.map((m, k) => { | |
const value = moment(k+1, 'M').format('MM') | |
return ({ value: value, label: m}) | |
}) | |
// component with the form: | |
const FormikExamplePage = (props) => { | |
// use the apiError from component state as we need to have it stored outside of the formik | |
// to not be reset after each validate cycle, but be reset only when we want | |
const [apiErrorFromComponentState, setApiErrorToComponentState] = useState(null) | |
return ( | |
<div style={{display:'flex', justifyContent: 'center', padding: 5}}> | |
<div style={{maxWidth: 600, width: '95%'}}> | |
<h3>Formik Test</h3> | |
<br/> | |
<Formik | |
initialValues={{ | |
// email initial value is required to be set to have the required validation: | |
email: '', | |
firstName: 'Greta', | |
lastName: 'Thunberg', | |
// not real field just to process validation errors errors | |
birthdate: '', | |
// fields to calculate other field birthdate | |
birthdateMonth: '04', | |
birthdateYear: '2009', | |
}} | |
validationSchema={yupSchema} | |
validate={(values) => { | |
const errors = {} | |
// to not let formik reset it on validation | |
if (apiErrorFromComponentState) { | |
errors.firstNameApi = apiErrorFromComponentState | |
} | |
validateBirthdateBySeparateInputsAsTheSingleField(values, errors) | |
return errors | |
}} | |
onSubmit={async (values) => { | |
const body = JSON.stringify({ | |
email: values.email, | |
birthdate: values.birthdate, | |
firstName: values.firstName, | |
lastName: values.lastName, | |
}) | |
// there should be the async function | |
// so don't forget to update your mapDispatchToProps to have the async function if you use redux action | |
// otherwise double click prevention will not work | |
await fetch(getUrl('some-url/formik-test'), { | |
method: 'POST', | |
body, | |
}) | |
// whatever other actions you need | |
await sleep(500) | |
alert('Submitted:' + JSON.stringify(body)) | |
}} | |
> | |
{({ | |
errors, | |
touched, | |
isSubmitting, | |
values, | |
setFieldError, | |
handleSubmit, | |
setSubmitting, | |
initialValues, | |
setFieldTouched, | |
handleBlur, | |
handleChange, | |
}) => ( | |
<Form> | |
Simple input for email with yup validation:<br/> | |
<div> | |
<Field name="email" type="email"/> | |
</div> | |
{ errors.email && touched.email ? | |
<div style={{color: 'red'}}>{errors.email}</div> : null | |
} | |
<br/><br/> | |
Computed input based on other inputs.<br/> | |
Added initial values to make the case more difficult<br/> | |
You can see usage of the react-select library with the formik here<br/> | |
<br/> | |
Year: | |
<Field name="birthdateYear" options={optionsYear} component={BirthdayField}/> | |
<br/> | |
Month: | |
<Field name="birthdateMonth" options={optionsMonth} component={BirthdayField}/> | |
{ errors.birthdate && touched.birthdate ? | |
<div style={{color: 'red'}}>{errors.birthdate}</div> : null | |
} | |
<br/><br/> | |
Simple input + yup validation + api validation:<br/> | |
<div> | |
{/* | |
* You can set the validate={validateFirstNameUsingApi} as props for formik.Field | |
* It is the simplest solution which just works out of the box | |
* But you maybe don't want to do it as it leads to delays during other validations | |
* For example user edits the email field | |
* Then the user see the error only when the api validation is done, despite yup rule doesn't need the api response to show the error | |
* Even if the api validates the firstName, not the email, user still need to wait to see the email error | |
* No solution currently is supported out of the box, see the https://github.com/formium/formik/issues/512 | |
* So, the component FirstNameFieldWithApiValidation is build to solve the issue | |
* Also form submitting is overwritten to support it | |
*/} | |
<FirstNameFieldWithApiValidation name="firstName" type="text" setApiErrorToGlobalState={setApiErrorToComponentState}/> | |
</div> | |
{/* Whatever logic of showing can be defined here, it is just example which looks reasonable for me */} | |
{ errors.firstName && touched.firstName ? | |
<div style={{color: 'red'}}>{errors.firstName}</div> | |
: errors.firstNameApi && touched.firstName ? | |
<div style={{color: 'red'}}>{errors.firstNameApi}</div> | |
: null | |
} | |
<br/> | |
<FormControl fullWidth margin="dense"> | |
<InputLabel htmlFor="lastName" shrink>Field from material ui:</InputLabel> | |
<Input | |
onChange={handleChange} | |
onBlur={handleBlur} | |
value={values.lastName} | |
name="lastName" | |
id="lastName" | |
/> | |
</FormControl> | |
<FormHelperText error={Boolean(errors.lastName)}>{ touched.lastName ? errors.lastName : null }</FormHelperText> | |
<br/> | |
Button with double click prevention:<br/><br/> | |
<div> | |
<Button | |
type="submit" | |
fullWidth | |
color="primary" | |
variant="raised" | |
onClick={async e => { | |
// overwritten formik submission just to add additional async validation before submitting | |
// to prevent sumbission by formik as we need prevent double click | |
e.preventDefault() | |
if (isSubmitting) { | |
return | |
} | |
setSubmitting(true) | |
// touch all fields to show the sync validation errors and don't force user to wait | |
Object.keys(initialValues).forEach(key => setFieldTouched(key)) | |
const apiError = await validateFirstNameUsingApi(values.firstName) | |
await setApiErrorToComponentState(apiError) | |
setFieldError('firstNameApi', apiError) | |
handleSubmit() | |
}}> | |
{ isSubmitting ? 'Submitting...' : 'Submit' } | |
</Button> | |
</div> | |
<br/><br/> | |
<pre>{JSON.stringify({values, errors, touched}, null, 2)}</pre> | |
</Form> | |
)} | |
</Formik> | |
</div> | |
</div> | |
) | |
} | |
// example of connection of react-select to the formik field | |
// for multiple selects can require some more tricky connection, but this work for the simple select | |
const ReactSelectForFormik = ({options, field, form, onChange = _ => {}}) => { | |
return ( | |
<Select | |
options={options} | |
name={field.name} | |
value={options ? options.find(option => option.value === field.value) : ''} | |
onChange={(option) => { | |
form.setFieldValue(field.name, option.value) | |
onChange() | |
}} | |
onBlur={field.onBlur} | |
className="selectMadpawsTheme" | |
classNamePrefix="madpaws-theme-select" | |
/> | |
) | |
} | |
// example of field which update other field | |
// based on this example: https://formik.org/docs/examples/dependent-fields | |
const BirthdayField = props => { | |
const { | |
values: { birthdateYear, birthdateMonth }, | |
setFieldValue, | |
setFieldTouched, | |
} = useFormikContext() | |
const isMount = useIsMount() | |
useEffect(() => { | |
// set the value of birthdate, based on birthdateYear and birthdateMonth: | |
if (birthdateYear && birthdateMonth) { | |
setFieldValue('birthdate', moment([birthdateYear, birthdateMonth - 1, 1]).format(DATE_API_FORMAT)) | |
// update the field as touched but not on the first appearance | |
// because we don't want to see the validation before user interacted with the field | |
// points to improve: maybe it is better to do it onBlur for birthdateYear and birthdateMonth | |
// to have more consistent behaviour with formik touch logic | |
if (!isMount) { | |
setFieldTouched('birthdate', true, false) | |
} | |
} | |
//set the value it only if something were changed, not the each render: | |
}, [birthdateYear, birthdateMonth]); | |
return ( | |
<ReactSelectForFormik {...props} /> | |
) | |
} | |
// example of field which is validated by api and yup both | |
const FirstNameFieldWithApiValidation = ({setApiErrorToGlobalState, ...props}) => { | |
const { | |
values: { firstName }, | |
errors: { firstName: firstNameError }, | |
setFieldError, | |
setFieldValue, | |
validateField, | |
} = useFormikContext() | |
const isMount = useIsMount() | |
useEffect(() => { | |
const doTheEffect = async () => { | |
// update the field as touched but not on the first appearance | |
// because we don't want to see the validation before user interacted with the field | |
if (isMount) { | |
return | |
} | |
// you can reset previous value if you want | |
await setApiErrorToGlobalState(null) | |
setFieldError('firstNameApi', null) | |
// just to trigger rerender once again to reflect the error disappears till the new request will return results | |
setFieldValue('firstName', firstName) | |
validateField('firstName') | |
// some error is already here, so no need to validate on api | |
// or maybe you want to do the api call and combine the errors, it's up to you | |
if (firstNameError) { | |
return | |
} | |
// you probably need some debounce mechanism here to not call api on each key down | |
const apiError = await validateFirstNameUsingApi(firstName) | |
setApiErrorToGlobalState(apiError) | |
// the field is named as "firstNameApi" to understand difference if the field have error from api or not | |
// it can help us to understand do we want to skip the api validation or not | |
// otherwise we can not skip it based on the errors.firstName as it can be api error there which will not appear for the next api call | |
setFieldError('firstNameApi', apiError) | |
} | |
doTheEffect() | |
// set the value it only if something were changed, not the each render | |
// it is also required to check if the firstNameError value was changed if you have the "if (firstNameError) { return }" | |
// it works this way because value can be changed, but the error still not, so the validation would be skipped | |
}, [firstName, firstNameError]); | |
return ( | |
<Field {...props} /> | |
) | |
} | |
// just workaround to not run effect on the first render | |
const useIsMount = () => { | |
const isMountRef = useRef(true); | |
useEffect(() => { | |
isMountRef.current = false; | |
}, []); | |
return isMountRef.current; | |
} | |
class Page extends Component { | |
static async getInitialProps() { | |
return {} | |
} | |
render() { | |
return ( | |
<FormikExamplePage /> | |
) | |
} | |
} | |
export default withRouter(withStyles(styles)(Page)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Result:
