Skip to content

Instantly share code, notes, and snippets.

@lpolito
Last active August 27, 2019 19:13
Show Gist options
  • Save lpolito/97342c841d9083db243d55d88cf28dcf to your computer and use it in GitHub Desktop.
Save lpolito/97342c841d9083db243d55d88cf28dcf to your computer and use it in GitHub Desktop.
useFormState hook
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)}),
};
};
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