Overview Handling form is an extremely common usecase for web applications. In this post, let's explore a way to handle form inputs and validations using React without using a third-party library. Requirements We will cover the most popular functionalities that will apply for most usecases: callback for components using the form. onSubmit Validation for single input (front-end only). Validation , not . onSubmit onBlur Reset form. How does it work? We will create a Form context that will hold all the states and define all state interactions for all form inputs. When an input is mounted, certain information passed in these inputs will be used to supply to the Form context. When an input within the form context changes, it will submit its new value to form context. Form context receives the values and changes its state to new value and pass it down to the input (controlled input). When the form is submitted, it will run through all the validations that was registered when the input mounted and set the errors for specific fields. Those will then be passed down to the right input and rendered accordingly. The figure below summarizes the responsibilities for each type of component. Implementation Form State This form state needs to be able to hold 3 pieces of information: Form data - for user's input data. Validations - for field specific validations. Errors - for field specific errors. I think this object should be suffice to work with. FORM_STATE = { : {}, : {}, : {}, }; const data validators errors We will also make a convention that every input must have a unique name prop to identify itself. It is similar to how a regular HTML5 form has name property. <input> It is important for the name to be unique because we will use them as keys in our state structure. For example, an input with name will be store in as follow: first_name FORM_STATE { : { : , }, : { : [fn()], }, : { : [ ], } } data first_name "John" validators first_name errors first_name "error message" Form Context To inject form state and methods to every components that want to subscribe to it, we will use context provider pattern. You can read more about context . here In my understanding, context is a wrapper that inject props into any child component that subscribe to it through a consumer. There is a convenient way to subscribe to context by using hook. useContext We will also create an HOC to encapsulate the context subscription logic in one place so that our input can be as purely UI as possible. In other words, inputs are presentational components that will only listen to prop changes. Form context is the container that will hold most of the logic. Form Methods Let's go through step by step how form context should behave. Registration When an input is mounted, it should register itself with form context. On registration, we simply will copy validators from that input to store inside form context. When an input is unmounted, we should clear its validations, errors, and any data associated with that input. Here's the registration function. registerInput = { setFormState( { { ...state, : { ...state.validators, [name]: validators || [] }, errors: { ...state.errors, [name]: [] } }; }); { setFormState( { { data, errors, : currentValidators } = { ...state }; data[name]; errors[name]; currentValidators[name]; { data, errors, : currentValidators }; }); }; }; const ( ) => { name, validators } => state return validators // clear any errors // returning unregister method return => () => state // copy state to avoid mutating it const validators // clear field data, validations and errors delete delete delete return validators The registration function will return a function to unregister this input. It will only remove that input with the same name. Input data control Controlled inputs require us to use an onChange function to set a value somewhere, either in a redux store or in a state. In our form, we will hijack it and set a value in our form context before passing up the value. That way, the input itself is more flexible, although, it does come with some confusions. When an input changes, we simply set its value to our form context's object. Here is the implementation. data setFieldValue = { setFormState( { { ...state, : { ...state.data, [name]: value }, : { ...state.errors, [name]: [] } }; }); }; const ( ) => name, value => state return data errors In addition to setting the input's data, we also clear its own errors under the assumption that if there was an error when the form submitted, user must have seen the inline errors. Now they're correcting the value for that field. Submission and validation Next, we have validation and submission part of the form. The process is simple. When user click submit, we'll run through every validators in form context, call the validator with 2 arguments: The value of the input. The data object as a whole. Why do we pass data object into validators? Technically, we don't have to, but I think it's nice to have the validator aware of the whole form data. That way, we can perform cross input validation if we want. If all validators return empty messages. It's good. The form will call callback. onSubmit If ANY validator return an error message, we'll set the errors hash with that input's name and error messages. The form is now invalid and callback will not be called. onSubmit Let's take a look at the implementation. validate = { { validators } = formState; setFormState( ({ ...state, : {} })); (isEmpty(validators)) { ; } formErrors = .entries(validators).reduce( { { data } = formState; messages = validators.reduce( { value = data[name]; err = validator(value, data); [...result, ...err]; }, []); (messages.length > ) { errors[name] = messages; } errors; }, {} ); (isEmpty(formErrors)) { ; } setFormState( ({ ...state, : formErrors })); ; }; const => () const // always reset form errors // in case there was form errors from backend => state errors if return true const Object ( ) => errors, [name, validators] const const ( ) => result, validator const const return if 0 return if return true => state errors return false It's important to note that we're running through all validators and collect all errors before returning. Otherwise, when user have errors in 2 fields, they'll have to fix one, submit, get another error, and fix the second field. That's it! We've got our form context ready. Here's the full code below. React, { useState } ; PropTypes ; { isEmpty } ; propTypes = { : PropTypes.object, : PropTypes.func.isRequired, : PropTypes.func }; initState = { { : { ...props.initialValues }, : {}, : {} }; }; FormContext; { Provider } = (FormContext = React.createContext()); Form = { [formState, setFormState] = useState(initState(props)); onSubmit = { e.preventDefault(); (validate()) { props.onSubmit(formState.data); } }; validate = { { validators } = formState; setFormState( ({ ...state, : {} })); (isEmpty(validators)) { ; } formErrors = .entries(validators).reduce( { { data } = formState; messages = validators.reduce( { value = data[name]; err = validator(value, data); [...result, ...err]; }, []); (messages.length > ) { errors[name] = messages; } errors; }, {} ); (isEmpty(formErrors)) { ; } setFormState( ({ ...state, : formErrors })); ; }; onReset = { e.preventDefault(); setFormState(initState(props)); (props.onReset) { props.onReset(); } }; setFieldValue = { setFormState( { { ...state, : { ...state.data, [name]: value }, : { ...state.errors, [name]: [] } }; }); }; registerInput = { setFormState( { { ...state, : { ...state.validators, [name]: validators || [] }, errors: { ...state.errors, [name]: [] } }; }); { setFormState( { { data, errors, : currentValidators } = { ...state }; data[name]; errors[name]; currentValidators[name]; { data, errors, : currentValidators }; }); }; }; providerValue = { : formState.errors, : formState.data, setFieldValue, registerInput }; ( <form onSubmit={onSubmit} onReset={onReset} className={props.className} id={props.id} > {props.children} </form> ); }; Form.propTypes = propTypes; Form; { FormContext }; import from "react" import from "prop-types" import from "lodash" const initialValues onSubmit onReset const => props return data validators errors let const const => props const const => e if const => () const // always reset form errors // in case there was form errors from backend => state errors if return true const Object ( ) => errors, [name, validators] const const ( ) => result, validator const const return if 0 return if return true => state errors return false const => e if const ( ) => name, value => state return data errors const ( ) => { name, validators } => state return validators // clear any errors // returning unregister method return => () => state // copy state to avoid mutating it const validators // clear field data, validations and errors delete delete delete return validators const errors data return < = > Provider value {providerValue} </ > Provider export default export Form HOC Now that we have form context, we will make an wrapper to inject those context methods into any input component. This is optional because you can always use a context hook. Though, I think it's convenient. The HOC will handle input registration, filtering errors and input value, and set data in form context. First, let's subscribe to form context with hook. useContext { errors, data, setFieldValue, registerInput } = useContext( FormContext ); const After that, we'll register to Form context with . useEffect useEffect( registerInput({ : props.name, : props.validators }), [] ); => () name validators We also return the unregistration function, so when this input is unmounted, it will not affect the form data or its validations anymore. Then, we need to get the right input value and error for the wrapped input. inputValue = data[props.name]; inputErrors = errors[props.name] || []; const const Error will always be an array of error messages. Empty error array means there are no errors. Lastly, we need to hijack the onChange callback so we can store this wrapped input's value to form context. onChange = { setFieldValue(props.name, val); (props.onChange) { props.onChange(val); } }; const => val if Here is the entire implementation. React, { useContext, useEffect } ; PropTypes ; { FormContext } ; propTypes = { : PropTypes.string.isRequired, : PropTypes.arrayOf(PropTypes.func) }; withForm = { WrappedWithForm = { { errors, data, setFieldValue, registerInput } = useContext( FormContext ); useEffect( registerInput({ : props.name, : props.validators }), [] ); onChange = { setFieldValue(props.name, val); (props.onChange) { props.onChange(val); } }; inputValue = data[props.name]; inputErrors = errors[props.name] || []; ( import from "react" import from "prop-types" import from "./index" const name validators const => InputComponent const => props const => () name validators const => val if const const return ); }; WrappedWithForm.propTypes = propTypes; return WrappedWithForm; }; export default withForm; < { } = = = /> InputComponent ...props errors {inputErrors} value {inputValue} onChange {onChange} Text Input Finally, something usable. Let's make a text input using our form. Our input will have the following: - A label - The input itself - Any errors - onChange callback It will receive in errors and value from form context. Based on form context, it will render accordingly. This is quite simple to implement. Here's the implementation. React ; PropTypes ; { isEmpty } ; classNames ; withForm ; ; propTypes = { : PropTypes.string, : PropTypes.string, : PropTypes.string, : PropTypes.string, : PropTypes.string, : PropTypes.arrayOf(PropTypes.string) }; defaultProps = { : , : }; TextInput = { hasError = !isEmpty(props.errors); renderErrors = { (!hasError) { ; } errors = props.errors.map( ( )); ; }; onChange = { val = e.target.value; props.onChange(val); }; klass = classNames( , { : hasError }); ( <label>{props.label}</label> <input name={props.name} type={props.type} className="form-control" placeholder={props.placeholder} onChange={onChange} value={props.value} /> {renderErrors()} </div> import from "react" import from "prop-types" import from "lodash" import from "classnames" import from "./with_form" import "./styles.css" const placeholder name value label type errors const value "" type "text" const => props const const => () if return null const ( ) => errMsg, i {errMsg} < = `${ } ${ }`} = > li key { props.name -error- i className "error" </ > li return {errors} < = > ul className "error-messages" </ > ul const => e const const "form-group" "has-error" return < = > div className {klass} ); }; TextInput.propTypes = propTypes; TextInput.defaultProps = defaultProps; const FormTextInput = withForm(TextInput); export { TextInput }; export default FormTextInput; All together now! We've arrive to the end! Yay! Let's put together a sign up form as an example. <Form onSubmit={data => .log(data)}> <TextInput name="last_name" validators={[requiredValidator]} placeholder="Smith" label="Last Name" /> // .... truncate <button className="submit-btn" type="submit"> Register! </button> <button className="submit-btn danger" type="reset"> Reset </button> </Form> console < = = = = /> TextInput name "first_name" validators {[requiredValidator]} placeholder "John" label "First Name" We'll simply log out the data for now. We'll also put in a few validators to make sure that it works. Let's take a look at a sample validator. requiredValidator = { (!val) { [ ]; } []; }; const => val if return "This field is required" return Try clicking submit and reset to see how it works! (CodeSandbox below) Thank you for reading to this point. I hope this is useful. Let me know your thoughts and comments :) Form in action