Last active
April 30, 2019 13:01
-
-
Save pbedat/c4523b4ce6df39ddd1eed5924967bdc6 to your computer and use it in GitHub Desktop.
React Hooks: useForm
This file contains hidden or 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, { useReducer } from "react"; | |
| import { Input, FormFeedback, Alert, Button, FormGroup } from "reactstrap"; | |
| import { createAction, ActionType, getType } from "typesafe-actions"; | |
| import { first, values, fromPairs, keyBy, mapValues } from "lodash"; | |
| /** | |
| * This story shows how the validation and form states should be display. | |
| * It is also a good example for hooks! | |
| */ | |
| const FormsUxStory = () => { | |
| const handleSubmit = (values: any) => { | |
| if (values.name === "Patrick") { | |
| return "404 - Record not found!"; | |
| } | |
| }; | |
| const { controls, submitFailed, submitError, onSubmit, invalid } = useForm( | |
| handleSubmit, | |
| { name: "name", validator: validators.required }, | |
| { name: "age" } | |
| ); | |
| const [name, age] = controls; | |
| return ( | |
| <form onSubmit={onSubmit}> | |
| <Alert hidden={!submitFailed} color="danger"> | |
| <em>{submitError}</em> | |
| </Alert> | |
| <FormGroup> | |
| <Input | |
| {...name.input} | |
| autoComplete="off" | |
| placeholder="Name (required)" | |
| invalid={(submitFailed || name.meta.touched) && name.meta.invalid} | |
| /> | |
| {(submitFailed || name.meta.touched) && name.meta.invalid && ( | |
| <FormFeedback invalid>{name.meta.error}</FormFeedback> | |
| )} | |
| </FormGroup> | |
| <FormGroup> | |
| <Input {...age.input} name="age" placeholder="Age" type="number" /> | |
| </FormGroup> | |
| <FormGroup> | |
| <Button color="primary" disabled={submitFailed && invalid}> | |
| Submit | |
| </Button> | |
| </FormGroup> | |
| <h3>Form State</h3> | |
| <pre>{JSON.stringify({ controls, submitFailed }, null, 2)}</pre> | |
| </form> | |
| ); | |
| }; | |
| export default () => <FormsUxStory />; | |
| /** | |
| * A special validator that always passes | |
| */ | |
| const NULL_VALIDATOR: ValidatorFn = () => undefined; | |
| function useForm<TControls extends string>( | |
| handleSubmit: (values: any) => undefined | string, | |
| ...controlDefs: Array<{ name: TControls; validator?: ValidatorFn }> | |
| ) { | |
| // add a default validator when no one is present | |
| const controls: FormControl[] = controlDefs.map(def => ({ | |
| validator: NULL_VALIDATOR, | |
| ...def | |
| })); | |
| const controlsMap = keyBy(controls, ctrl => ctrl.name); | |
| // create the initial state | |
| const initialState = { | |
| touched: mapValues(controlsMap, () => false), | |
| values: mapValues(controlsMap, () => undefined), | |
| errors: mapValues(controlsMap, ({ name, validator }) => validator(undefined, name)), | |
| submitError: undefined | |
| }; | |
| // setup the reducer by passing the controls through a closure | |
| const [state, dispatch] = useReducer(createReducer(controls), initialState); | |
| // this is basically a mapStateToProps and mapDispatchToProps | |
| const connectedControls = controls.map(control => { | |
| const { name } = control; | |
| return { | |
| // props that can be mapped directly to input elements | |
| input: { | |
| name, | |
| onChange(ev: React.ChangeEvent<HTMLInputElement>) { | |
| dispatch(formActions.change(name, ev.currentTarget.value)); | |
| }, | |
| onBlur() { | |
| dispatch(formActions.blur(name)); | |
| }, | |
| value: state.values[name] | |
| }, | |
| // meta information about a control | |
| meta: { | |
| touched: state.touched[name], | |
| valid: !state.errors[name], | |
| invalid: !!state.errors[name], | |
| error: state.errors[name] | |
| } | |
| }; | |
| }); | |
| return { | |
| controls: connectedControls, | |
| invalid: connectedControls.some(ctrl => ctrl.meta.invalid), | |
| touched: connectedControls.some(ctrl => ctrl.meta.touched), | |
| submitFailed: !!state.submitError, | |
| submitError: state.submitError, | |
| onSubmit(ev: React.FormEvent<HTMLFormElement>) { | |
| ev.preventDefault(); | |
| const error = handleSubmit(state.values); | |
| if (error) { | |
| return dispatch(formActions.submit(error)); | |
| } | |
| dispatch(formActions.submit()); | |
| } | |
| }; | |
| } | |
| const validators = { | |
| required(value, name) { | |
| return value ? undefined : `Field '${name}' is required`; | |
| } | |
| }; | |
| interface FormState<Controls extends string = string> { | |
| /** | |
| * A control is touched when it once got and lost the focus | |
| */ | |
| touched: Partial<Record<Controls, boolean>>; | |
| /** | |
| * The values for each control | |
| */ | |
| values: Record<Controls, undefined | any>; | |
| /** | |
| * If a control does not pass its validator, it receives an error | |
| */ | |
| errors: Record<Controls, undefined | string>; | |
| /** | |
| * The first error that prevented the submit | |
| */ | |
| submitError: undefined | string; | |
| } | |
| interface FormControl { | |
| name: string; | |
| validator: ValidatorFn; | |
| } | |
| type ValidatorFn = (value: string, name: string) => undefined | string; | |
| const formActions = { | |
| blur: createAction("blur", _ => (input: string) => _({ input })), | |
| change: createAction("change", _ => (input: string, value: any) => _({ input, value })), | |
| submit: createAction("submit", _ => (error?: string) => _(error)) | |
| }; | |
| function createReducer(controls: FormControl[]) { | |
| return function formReducer(state: FormState, action: ActionType<typeof formActions>) { | |
| switch (action.type) { | |
| case getType(formActions.blur): { | |
| const { touched, ...rest } = state; | |
| const { input } = action.payload; | |
| // touch that input! | |
| return { touched: { ...touched, [input]: true }, ...rest }; | |
| } | |
| case getType(formActions.change): { | |
| const { errors, values, ...rest } = state; | |
| const { input, value } = action.payload; | |
| const validator = controls.find(({ name }) => name === input).validator; | |
| return { | |
| ...rest, | |
| // update the current value of the control | |
| values: { ...values, [input]: value }, | |
| // set or remove the error of the input | |
| errors: { ...errors, [input]: validator(value, input) } | |
| }; | |
| } | |
| case getType(formActions.submit): { | |
| return { | |
| ...state, | |
| // the submit fails when there are errors buddy! | |
| submitError: action.payload || first(values(state.errors)) | |
| }; | |
| } | |
| } | |
| return state; | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment