Skip to content

Instantly share code, notes, and snippets.

@pbedat
Last active April 30, 2019 13:01
Show Gist options
  • Select an option

  • Save pbedat/c4523b4ce6df39ddd1eed5924967bdc6 to your computer and use it in GitHub Desktop.

Select an option

Save pbedat/c4523b4ce6df39ddd1eed5924967bdc6 to your computer and use it in GitHub Desktop.
React Hooks: useForm
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