Last active
August 27, 2019 19:13
-
-
Save lpolito/97342c841d9083db243d55d88cf28dcf to your computer and use it in GitHub Desktop.
useFormState hook
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
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import {useOnUpdateEffect} from 'hooks/use-on-update-effect.mjs'; | |
// Validate form field value simply exists. | |
export const validateExists = (error) => (value) => (!value ? error : null); | |
export const formItemPropType = PropTypes.shape({ | |
value: PropTypes.any, | |
dirty: PropTypes.bool.isRequired, | |
error: PropTypes.string, | |
touched: PropTypes.bool.isRequired, | |
disabled: PropTypes.bool, | |
validator: PropTypes.func.isRequired, | |
disabler: PropTypes.func, | |
update: PropTypes.func.isRequired, | |
touch: PropTypes.func.isRequired, | |
}); | |
// Create object of key:values of all form items. | |
const getFormValues = (formItems) => ( | |
Object.entries(formItems) | |
.reduce((acc, [k, v]) => ({ | |
...acc, | |
[k]: v.value, | |
}), {}) | |
); | |
const getValidatedFormState = (state) => { | |
const { | |
form, | |
...rest | |
} = state; | |
// Get all form values for validator. | |
const formValues = getFormValues(form); | |
let isFormValid = true; | |
return { | |
...rest, | |
// Check validation on every form item. | |
form: Object.entries(form) | |
.reduce((acc, [formItemKey, formItem]) => { | |
const { | |
value: formValue, | |
validator, | |
disabler, | |
} = formItem; | |
// Check if formItem needs to be disabled. Ignore validation if it is. | |
const disabled = disabler(formValues); | |
const error = (!disabled) | |
? validator(formValue, formValues) | |
: null; | |
if (error) isFormValid = false; | |
return { | |
...acc, | |
[formItemKey]: { | |
...formItem, | |
error, | |
disabled, | |
}, | |
}; | |
}, {}), | |
valid: isFormValid, | |
}; | |
}; | |
const formReducer = (prevState, {type, payload}) => { | |
const {form} = prevState; | |
switch (type) { | |
// Update a given field by key with provided value. | |
case 'update': { | |
// Update an individual value by key. | |
const newState = { | |
...prevState, | |
// TODO check that key already exists in state before populating it here | |
form: { | |
...form, | |
[payload.key]: { | |
...form[payload.key], | |
value: payload.value, | |
dirty: true, | |
}, | |
}, | |
}; | |
return { | |
...getValidatedFormState(newState), | |
dirty: true, | |
}; | |
} | |
// Touch a given field by key. | |
case 'touch': | |
return { | |
...prevState, | |
form: { | |
...form, | |
[payload.key]: { | |
...form[payload.key], | |
touched: true, | |
}, | |
}, | |
}; | |
// Mark form as submitted. | |
case 'submit': | |
return { | |
...prevState, | |
submitted: true, | |
}; | |
// Reset form to original state. | |
case 'reset': | |
return payload; | |
default: | |
return prevState; | |
} | |
}; | |
const createFormItem = ({ | |
key, value = '', validator = () => null, disabler = () => false, | |
}) => ({ | |
key, | |
// Value of field - can be any type or shape. | |
value, | |
// If a user has changed a field at all - even if it means changing it back to its original state. | |
// Only returns to false when updated formItems are provided. | |
dirty: false, | |
// If the field doesn't pass provided validator - can be any type or shape as long form field can ingest it. | |
// Null representing no error. | |
error: null, | |
// If a user has focused then blurred a field. | |
touched: false, | |
// If a field is disabled. | |
disabled: false, | |
// Function to validate form field - takes two arguments: value of field, and key:values of all other fields. | |
// Can return any type or shape as long form field can ingest it - should return null if no errors are present. | |
validator, | |
// Function to disable form field - takes argument of key:values of all other fields. | |
// Should return true if disabled; false if enabled. | |
disabler, | |
}); | |
const createInternalState = (formItems) => { | |
const unvalidatedInitialState = { | |
form: formItems.reduce((acc, { | |
key, value, validator, disabler, | |
}) => ({ | |
...acc, | |
[key]: createFormItem({ | |
key, | |
value, | |
validator, | |
disabler, | |
}), | |
}), {}), | |
valid: undefined, | |
submitted: false, | |
dirty: false, | |
}; | |
return getValidatedFormState(unvalidatedInitialState); | |
}; | |
const createFormObject = (form, dispatch) => ( | |
Object.entries(form) | |
.reduce((acc, [key, itemValue]) => ({ | |
...acc, | |
[key]: { | |
...itemValue, | |
update: (value) => dispatch({type: 'update', payload: {key, value}}), | |
touch: () => dispatch({type: 'touch', payload: {key}}), | |
}, | |
}), {}) | |
); | |
/** | |
* @param formItems {array|function} Array of, or function that returns array of, | |
* form items being tracked and validated. | |
* e.g. () => [{ | |
* key: 'fooBar', | |
* value: '', | |
* validator: () => null, | |
* disabler: () => false, | |
* }] | |
* @param dependencies {array} Dependencies for when to update form state with new formItems. | |
*/ | |
export const useFormState = (formItems, dependencies = []) => { | |
const [previousFormItems, setPreviousFormItems] = React.useState(formItems); | |
const [formState, dispatch] = React.useReducer(formReducer, createInternalState(previousFormItems)); | |
useOnUpdateEffect(() => { | |
const newFormItems = typeof formItems === 'function' | |
? formItems() : formItems; | |
const newFormState = createInternalState(newFormItems); | |
dispatch({type: 'reset', payload: newFormState}); | |
setPreviousFormItems(newFormItems); | |
}, dependencies); | |
const { | |
form, | |
valid, | |
submitted, | |
dirty, | |
} = formState; | |
const formObject = createFormObject(form, dispatch); | |
return { | |
// Form object with built in functions for interacting with form. | |
form: formObject, | |
// Form's validity. | |
valid, | |
// Form's submitted state. | |
submitted, | |
// Form's dirty state. | |
dirty, | |
// Function to check whether an individual form item should show error. | |
formItemHasError: (formItem) => ( | |
Boolean(formItem.error) && (formItem.dirty || formItem.touched || submitted) | |
), | |
submit: () => dispatch({type: 'submit'}), | |
reset: () => dispatch({type: 'reset', payload: createInternalState(previousFormItems)}), | |
}; | |
}; |
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
import React from 'react'; | |
import ipRegex from 'ip-regex'; | |
import TextField from '@material-ui/core/TextField/TextField.js'; | |
import Button from '@material-ui/core/Button/Button.js'; | |
import { | |
useFormState, | |
formItemHasError, | |
validateExists, | |
} from './use-form-state.mjs'; | |
// this array defines the items we are tracking (whether it ties to a form field or not), | |
// and the validation on those items. | |
const FORM_ITEMS = [ | |
{ | |
key: 'id', | |
value: null, | |
}, | |
{ | |
key: 'name', | |
validator: validateExists('Name is required.'), // validateExists simply checks if field is not empty | |
}, | |
{ | |
key: 'userIp', | |
validator: (userIp) => { | |
// first argument of validator receives the current value for this form item | |
// this validator can return different error messages depending on input value | |
if (!userIp) { | |
return 'User IP is required.'; | |
} | |
if (userIp && !ipRegex({exact: true}).test(userIp)) { | |
return 'Invalid IP address.'; | |
} | |
return null; | |
}, | |
}, | |
{ | |
key: 'port', | |
validator: (port, {userIp}) => { | |
// second argument is key:values object of all other form entries | |
// this is useful when certain form items depend on value of other form items | |
if (userIp && !userIp.includes('127.') && !port) { | |
return 'External IPs require port.'; | |
} | |
return null; | |
}, | |
}, | |
]; | |
export const ExampleForm = () => { | |
// handle form state with useFormState hook | |
const { | |
form: { | |
id, name, userIp, port, | |
}, | |
valid: formValid, dirty: formDirty, submitted: formSubmitted, submit: submitForm, | |
} = useFormState(FORM_ITEMS); | |
const formAction = () => { | |
// tell the form that it's been submitted | |
submitForm(); | |
// form validity is tracked at all times, so we just need to check it when deciding to continue an action | |
if (!formValid) { | |
return; | |
} | |
// perform rest of action | |
}; | |
// text fields use formItemHasError for error - true if the field has an error and is dirty, touched, or the form is submitted | |
return ( | |
<> | |
<TextField | |
label='Name' | |
value={name.value} | |
onChange={(e) => name.update(e.target.value)} | |
onBlur={() => name.touch()} | |
error={formItemHasError(name, formSubmitted)} | |
helperText={formItemHasError(name, formSubmitted) ? name.error : ''} | |
required | |
/> | |
<TextField | |
label='User IP' | |
value={userIp.value} | |
onChange={(e) => userIp.update(e.target.value)} | |
onBlur={() => userIp.touch()} | |
error={formItemHasError(userIp, formSubmitted)} | |
helperText={formItemHasError(userIp, formSubmitted) ? userIp.error : 'Please enter a valid IP.'} | |
required | |
/> | |
<TextField | |
label='Port' | |
value={port.value} | |
onChange={(e) => port.update(e.target.value)} | |
onBlur={() => port.touch()} | |
error={formItemHasError(port, formSubmitted)} | |
helperText={formItemHasError(port, formSubmitted) ? port.error : ''} | |
number | |
/> | |
<Button onClick={() => formAction()}> | |
Submit | |
</Button> | |
<Button | |
onClick={() => formAction()} | |
disabled={(formSubmitted && !formValid) || !formDirty} | |
> | |
This submit is disabled when form submitted and invalid, or when form is not dirty | |
</Button> | |
</> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment