fielder
Version:
A field-first form library for React and React Native
297 lines (256 loc) • 7.87 kB
text/typescript
import { useCallback, useMemo, useRef, MutableRefObject } from 'react';
import {
Dispatch,
FormState,
FieldState,
FieldsState,
FormSchemaType,
} from './types';
import { BlurFieldAction, doBlurField } from './actions/blurField';
import { MountFieldAction, doMountField } from './actions/mountField';
import { SetFieldStateAction, doSetFieldState } from './actions/setFieldState';
import {
SetFieldValidationAction,
doSetFieldValidation,
} from './actions/setFieldValidation';
import { SetFieldValueAction, doSetFieldValue } from './actions/setFieldValue';
import { UnmountFieldAction, doUnmountField } from './actions/unmountField';
import { ValidateFieldAction } from './actions/validateField';
import { ValidateSubmissionAction } from './actions/validateSubmission';
import { applyValidationToState } from './validation/applyValidationToState';
import { batchValidationErrors } from './validation/batchValidationErrors';
import { useSynchronousReducer } from './useSynchronousReducer';
export type FormAction =
| BlurFieldAction
| MountFieldAction
| SetFieldStateAction
| SetFieldValidationAction
| SetFieldValueAction
| UnmountFieldAction
| ValidateFieldAction
| ValidateSubmissionAction;
export type UseFormOpts = { fromState?: Record<string, any> };
export const useForm = <T extends FormSchemaType = FormSchemaType>(
opts: UseFormOpts = {}
): FormState<T> => {
/** Async validation promise updated synchronously after every dispatch. */
const promiseRef = useRef<Record<string, Promise<any>> | undefined>();
/** Reference to dispatch for forwarding closure. */
const dispatchRef = useRef<Dispatch<FormAction>>();
const handleAsyncValidation = useMemo(
() => createHandleAsyncValidation(dispatchRef),
[]
);
const initialState = useMemo(
() =>
opts.fromState
? Object.entries(opts.fromState).reduce<FieldsState>(
(p, [key, value]) => ({
...p,
[key]: {
_isActive: false,
name: key,
value,
isValid: false,
isValidating: false,
hasBlurred: false,
hasChanged: false,
},
}),
{}
)
: {},
[]
);
const [fields, dispatch] = useSynchronousReducer<
FieldsState<any>,
FormAction
>((state, action) => {
const newState = applyActionToState(state, action);
const { state: validatedState, promises } = applyValidationToState(
newState,
action
);
if (Object.keys(promises).length > 0) {
// Maybe we should batch async updates caused by a single action
Object.entries(promises).map(([name, promise]) =>
handleAsyncValidation(name, promise)
);
}
promiseRef.current = promises;
return validatedState;
}, initialState);
useMemo(() => (dispatchRef.current = dispatch), [dispatch]);
/** Retrieves field state for initial mount (before mounted field is added to state) */
const premountField = useCallback<FormState<T>['premountField']>(
(config) => {
const action = {
type: 'MOUNT_FIELD',
config,
} as const;
const newState = applyActionToState(fields, action);
const { state: validatedState } = applyValidationToState(
newState,
action
);
// Throw away async validation on mount and
// wait for mountField call during commit
return validatedState[config.name] as any;
},
[fields]
);
const mountField = useCallback<FormState<T>['mountField']>(
(config) => dispatch({ type: 'MOUNT_FIELD', config })[config.name] as any,
[dispatch]
);
const unmountField = useCallback<FormState<T>['unmountField']>(
(config) => dispatch({ type: 'UNMOUNT_FIELD', config }),
[dispatch]
);
const setFieldValue = useCallback<FormState<T>['setFieldValue']>(
(config) => dispatch({ type: 'SET_FIELD_VALUE', config }),
[dispatch]
);
const setFieldState = useCallback<FormState<T>['setFieldState']>(
(config) => dispatch({ type: 'SET_FIELD_STATE', config }),
[dispatch]
);
const blurField = useCallback<FormState<T>['blurField']>(
(config) => dispatch({ type: 'BLUR_FIELD', config: config }),
[dispatch]
);
const setFieldValidation = useCallback<FormState<T>['setFieldValidation']>(
(config) => dispatch({ type: 'SET_FIELD_VALIDATION', config }),
[dispatch]
);
const validateField = useCallback<FormState<T>['validateField']>(
(config) => dispatch({ type: 'VALIDATE_FIELD', config }),
[dispatch]
);
const validateSubmission = useCallback<
FormState<T>['validateSubmission']
>(() => {
const newState = dispatch({ type: 'VALIDATE_SUBMISSION' });
const errors = batchValidationErrors({
state: newState,
promises: promiseRef.current,
});
if (errors instanceof Promise) {
return errors.then((errors) => ({
state: newState,
errors,
}));
}
return {
state: newState,
errors,
};
}, [dispatch]);
const mountedFields = useMemo(
() =>
Object.values(fields as FieldsState).filter(
(f) => !(f === undefined || !f._isActive)
) as FieldState[],
[fields]
);
const isValid = useMemo(() => mountedFields.every((f) => f.isValid), [
mountedFields,
]);
const isValidating = useMemo(
() => mountedFields.some((f) => f.isValidating),
[mountedFields]
);
return useMemo(
() => ({
fields,
isValid,
isValidating,
premountField,
setFieldValue,
blurField,
validateField,
validateSubmission,
mountField,
unmountField,
setFieldState,
setFieldValidation,
}),
[
fields,
isValid,
isValidating,
premountField,
setFieldValue,
blurField,
validateField,
validateSubmission,
mountField,
unmountField,
setFieldState,
setFieldValidation,
]
);
};
/** Triggers primary action to state. */
const applyActionToState = (s: FieldsState, a: FormAction) => {
if (a.type === 'MOUNT_FIELD') {
return doMountField(s)(a.config);
}
if (a.type === 'UNMOUNT_FIELD') {
return doUnmountField(s)(a.config);
}
if (a.type === 'SET_FIELD_VALUE') {
return doSetFieldValue(s)(a.config);
}
if (a.type === 'BLUR_FIELD') {
return doBlurField(s)(a.config);
}
if (a.type === 'SET_FIELD_STATE') {
return doSetFieldState(s)(a.config);
}
if (a.type === 'SET_FIELD_VALIDATION') {
return doSetFieldValidation(s)(a.config);
}
return s;
};
/** Tracks async validation per field and updates state on completion. */
const createHandleAsyncValidation = (
dispatch: MutableRefObject<Dispatch<FormAction> | undefined>
) => {
let promises: Record<string, Promise<any>> = {};
return (name: string, validationPromise: Promise<any>) => {
// Add promise to collection
promises = {
...promises,
[name]: validationPromise,
};
const validationCallback = (isError: boolean) => (response: any) => {
if (!dispatch || !dispatch.current) {
console.warn(
'Unable to update validation state. Dispatch not available.'
);
return;
}
// Newer validation promise has been called
if (promises[name] !== validationPromise) {
return;
}
const isValid = !isError;
dispatch.current({
type: 'SET_FIELD_STATE',
config: {
name,
state: (s: any) => ({
...s,
isValidating: false,
isValid,
error: isValid ? undefined : response.message,
}),
},
});
};
validationPromise
.then(validationCallback(false))
.catch(validationCallback(true));
};
};