Skip to content

Instantly share code, notes, and snippets.

@loburets
Last active December 20, 2021 11:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save loburets/ebbdb302ae01ef2a854a4d21ea2de608 to your computer and use it in GitHub Desktop.
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.
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))
@loburets
Copy link
Author

loburets commented Oct 7, 2020

This example contains:

  • Computed input: single input with value created by few other inputs. Here it's the date of birth that was produced by 3 separate selects but validated and sent as a single field.
  • Yup validation together with API validation. Both works parallelly, you don't need to wait for API response to show yup errors as soon as they appear in the user's input.
  • Button double click prevention to exclude double form sending
  • Input from material UI used

@loburets
Copy link
Author

loburets commented Oct 7, 2020

Result:
124

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment