Skip to content

Instantly share code, notes, and snippets.

@lpolito
Last active July 2, 2019 14:11
Show Gist options
  • Save lpolito/71e83bbdf5252845102071aae37deadc to your computer and use it in GitHub Desktop.
Save lpolito/71e83bbdf5252845102071aae37deadc to your computer and use it in GitHub Desktop.
import React from 'react';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual.js';
import {useComplexState} from 'hooks/use-complex-state.mjs';
import {useOnUpdateEffect} from 'hooks/use-on-update-effect.mjs';
// FormItems contexts
const FormItemsContext = React.createContext();
const FormActionsContext = React.createContext();
const getMatchingFormItemEntry = (keyToFind, formItems) => (
// Get form item entries by matching key.
Object.entries(formItems).find(([key]) => key === keyToFind)
);
const formItemsReducer = (prevItems, payload) => {
switch (payload.action) {
case 'register':
case 'sync': {
// formItemToPersist will be empty object if doesn't exist yet.
const formItemToPersist = prevItems[payload.key]
? prevItems[payload.key]
: {
// Make sure subscriptions are on all form items.
subscriptions: [],
};
const updatedFormItem = {
...formItemToPersist,
...payload.formItem,
};
// currentValue can be undefined if this was a new formItem.
const currentValue = formItemToPersist.localState ? formItemToPersist.localState.value : undefined;
// Call form item's subscriptions if needed.
if (updatedFormItem.subscriptions.length
&& !isEqual(currentValue, payload.formItem.localState.value)) {
// Value changed between syncs, call subscription with latest value / state.
formItemToPersist.subscriptions.forEach((callback) => {
callback(updatedFormItem.localState.value, updatedFormItem.localState);
});
}
return {
...prevItems,
[payload.key]: updatedFormItem,
};
}
case 'unregister': {
const {[payload.key]: removed, ...rest} = prevItems;
return rest;
}
case 'addSubscription': {
// When adding a new subscription check if the subscribed key already exists.
// If it does, call the new callback with the pertinent value / state so the subscriber is always up to date.
const [key, formItem] = getMatchingFormItemEntry(payload.key, prevItems) || [];
if (key && formItem) {
// localState is undefined if a form item is subscribed to but hasn't been init'd yet.
if (formItem.localState) {
payload.callback(formItem.localState.value, formItem.localState);
}
// Update existing form items subscriptions with additional callback.
const updateFormItem = {
[key]: {
...formItem,
subscriptions: [...formItem.subscriptions, payload.callback],
},
};
return {
...prevItems,
...updateFormItem,
};
}
// Create a new formItem with the given key.
// This happens when a subscription is added before a formItem with the given key is initialized.
// This is making the assumption that the given key will be valid at some point.
const newFormItem = {
[payload.key]: {
subscriptions: [payload.callback],
},
};
return {
...prevItems,
...newFormItem,
};
}
case 'removeSubscription': {
// Update existing form items by removing subscription.
const [key, formItem] = getMatchingFormItemEntry(payload.key, prevItems) || [];
const updateFormItem = {
[key]: {
...formItem,
subscriptions: formItem.subscriptions.filter((callback) => callback !== payload.callback),
},
};
return {
...prevItems,
...updateFormItem,
};
}
case 'touch':
// Call internal touch functions.
Object.values(prevItems).forEach((formItem) => formItem.touch());
return prevItems;
case 'reset':
// Call internal reset functions.
Object.values(prevItems).forEach((formItem) => formItem.reset());
return prevItems;
default:
return prevItems;
}
};
const useFormItems = () => React.useContext(FormItemsContext);
export const useFormActions = () => React.useContext(FormActionsContext);
const FormItemsProvider = (props) => {
const [formItems, dispatch] = React.useReducer(formItemsReducer, {});
// In practice actionsContext should never be recalculated because dispatch is stable.
// This is important because FormItemActionsContext consumers shouldn't rerender on formItems changes.
const actionsContext = React.useMemo(() => ({
register: (key, formItem) => {
dispatch({action: 'register', key, formItem});
return () => dispatch({action: 'unregister', key});
},
sync: (key, formItem) => dispatch({action: 'sync', key, formItem}),
subscribeByKey: (key, callback) => {
dispatch({action: 'addSubscription', key, callback});
return () => {
dispatch({action: 'removeSubscription', key, callback});
};
},
touch: () => dispatch({action: 'touch'}),
reset: () => dispatch({action: 'reset'}),
}), [dispatch]);
return (
<FormItemsContext.Provider value={formItems}>
<FormActionsContext.Provider value={actionsContext} {...props} />
</FormItemsContext.Provider>
);
};
// FormValues context
const FormValuesContext = React.createContext();
export const useFormValues = () => React.useContext(FormValuesContext);
// Create object of key:values of all form items.
const reduceFormValues = (formItems) => (
Object.entries(formItems)
.reduce((acc, formItemEntry) => {
const [key, formItem] = formItemEntry;
// localState is undefined if a form item is subscribed to but hasn't been init'd yet.
if (!formItem.localState) return acc;
const {localState} = formItem;
// Ignore disabled fields.
if (localState.disabled) return acc;
return {
...acc,
[key]: localState.value,
};
}, {})
);
const FormValuesProvider = (props) => {
const formItems = useFormItems();
const formValues = reduceFormValues(formItems);
return (
<FormValuesContext.Provider value={formValues} {...props} />
);
};
// Form validity context
const FormValidityContext = React.createContext();
export const useFormValidity = () => React.useContext(FormValidityContext);
const FormValidityProvider = ({validator, ...props}) => {
const formItems = useFormItems();
// localState is undefined if a form item is subscribed to but hasn't been init'd yet.
const formItemValues = Object.values(formItems).filter((formItem) => formItem.localState);
const formItemsValid = formItemValues.every(({localState}) => localState.valid);
const formIsDirty = formItemValues.some(({localState}) => localState.dirty);
const formValues = useFormValues();
const formErrors = validator(formValues);
const context = React.useMemo(() => ({
valid: formItemsValid && !formErrors,
formErrors,
dirty: formIsDirty,
}), [formItemsValid, formErrors, formIsDirty]);
return (
<FormValidityContext.Provider value={context} {...props} />
);
};
FormValidityProvider.propTypes = {
validator: PropTypes.func.isRequired,
};
export const FormProvider = ({validator, ...rest}) => (
<FormItemsProvider>
<FormValuesProvider>
<FormValidityProvider validator={validator} {...rest} />
</FormValuesProvider>
</FormItemsProvider>
);
FormProvider.propTypes = {
validator: PropTypes.func,
};
FormProvider.defaultProps = {
validator: () => null,
};
const defaultFormItemState = ({value, ...props}) => ({
// Initial 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 form item's initial value is updated via props.
dirty: false,
// If a user has interacted with the field.
touched: 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 field is disabled. Disabled fields are not validated.
disabled: false,
// If field is valid.
valid: true,
...props,
});
const getValidityState = (localState, validator) => {
// If there is no validator specified, always assume valid.
if (validator === undefined) {
return {
valid: true,
error: null,
};
}
const error = localState.disabled ? null : validator(localState.value);
return {
valid: !error,
error,
};
};
/**
* @param {Object} options
* @param {string} options.key Unique key that identifies this field.
* @param {any} options.value Initial value of field. Can be any type or shape.
* @param {string} options.validator Function which accepts value + form values to signify item validity.
*/
export const useFormItem = ({
key, value: initialValue = '', validator,
}) => {
const {register, sync} = useFormActions();
const [localState, setLocalState] = useComplexState(() => {
const defaultState = defaultFormItemState({value: initialValue});
return {
...defaultState,
...getValidityState(defaultState, validator),
};
});
const validate = () => {
setLocalState(getValidityState(localState, validator));
};
const update = (newValue) => {
// Reducer function is being used to derive new state from previous state.
setLocalState((prevState) => {
const updatedState = {
...prevState,
value: typeof newValue === 'function' ? newValue(prevState.value) : newValue,
dirty: true,
};
// Bake in validation on value update.
return {
...updatedState,
...getValidityState(updatedState, validator),
};
});
};
const disable = (disabled) => setLocalState({disabled});
const touch = () => setLocalState({touched: true});
// Always append validityState so validity is up to date.
const reset = (newValue = initialValue) => {
const defaultState = defaultFormItemState({value: newValue});
setLocalState({
...defaultState,
...getValidityState(defaultState, validator),
});
};
useOnUpdateEffect(() => {
// Reset form item when initial value changes.
reset();
}, [initialValue]);
React.useEffect(() => (
// On init this registers the form item to context and unregister on unmount (register resturns a function).
register(key, {
localState,
validate,
reset,
touch,
})
), []);
useOnUpdateEffect(() => {
// On localState change this lets parent context know the latest localState.
sync(key, {
localState,
validate,
reset,
touch,
});
}, [localState, validator]);
React.useEffect(() => {
validate();
}, [localState.touched, localState.disabled, validator]);
return {
...localState,
shouldShowError: (localState.dirty || localState.touched) && !localState.valid,
validate,
update,
disable,
touch,
reset,
};
};
export const useFormValue = (key) => {
const {subscribeByKey} = useFormActions();
const [value, setValue] = React.useState(null);
React.useEffect(() => (
subscribeByKey(key, (newValue) => setValue(newValue))
), []);
return value;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment