React context custom input validation displays all errors when typing in any input

Viewed 45

What I'm looking for is to have my custom input validation to show errors input by input, as I type into them (not on submit). What it does now is showing every errors as soon as I type inside any input. I know the reason : because setErrors() is in handleFormChange(), therefore every errors are set as soon as I type in any input, but I can't find a solution that meets my needs.

What I try to do is put most of the form's logic in the Form component, as it is where FormContext is defined. I believe doing so will give me more reusability, as the only parts I would need to change to reuse the components are only the validate function (to define the input's rules) and the onSubmit function, that are located in the parent component (here App). No need to touch anything on Form or FormInput when everything will be set.

I know react-hook-form and how to use it but I would like to create my own form / input validation logic. Everything works as needed expect this error display problem.

Here is the Form component (including FormContext) :

export const FormContext = createContext({
    form: {},
    errors: {},
});

export default function Form({
    formInitialValues,
    onSubmit = () => {},
    validate = () => {},
    children
}) {
    const [form, setForm] = useState(formInitialValues);
    const [errors, setErrors] = useState({});
    
    const handleFormChange = (e) => {
        const { name, value } = e.target || e;
        
        const updatedForm = {
            ...form,
            [name]: value,
        };
        
        const errors = validate(updatedForm);
        
        setForm(updatedForm);
        setErrors(errors);
    };
    
    return (
        <FormComp>
            <FormContext.Provider value={{
                form,
                errors,
                handleFormChange,
            }}>
                { children }
            </FormContext.Provider>

            <button type="button" onClick={() => onSubmit(form)}>
                Envoyer
            </button>
        </FormComp>
    )
}

Here is my FormInput component :

export default function FormInput({
    label,
    type = 'text',
    name,
    forwardRef,
}) {
    const formContext = useContext(FormContext);
    const { form, errors, handleFormChange } = formContext;
    
    const inputRef = useRef(null);

    return (
        <InputGroup>
            <label>{ label }</label>
            <input
                type={type}
                name={name}
                value={form[name]}
                onChange={handleFormChange}
                ref={forwardRef ? forwardRef : inputRef} />
            { errors && errors[name] && errors[name].length > 0 && <span>{ errors[name] }</span> }
        </InputGroup>
    )
}

And here is my App component, where I define the validate function (that is used in Form -> handleFormChange()) :

function App() {
    const initialValues = {
        lastName: '',
        firstName: '',
    };
    
    const validate = (form) => {
        let errors = {};

        if (form.lastName === '') {
            errors.lastName = 'Last name is required'
        }
        if (form.firstName === '') {
            errors.firstName = 'This field is required';
        }
        
        return errors;
    };
    
    const onSubmit = (form) => {
        console.log({ form });
    };
    
  return (
    <div className="App">
        <h1>S'inscrire</h1>
        
        <Form
            formInitialValues={initialValues}
            onSubmit={onSubmit}
            validate={validate}
        >
            <FormInput
                label="Nom"
                name="lastName" />
            <FormInput
                label="Prénom"
                name="firstName" />
        </Form>
    </div>
  );
}

export default App;

Could anyone point me in the right direction ? I tried lots of stuff but nothing works (at least nothing that meets my needs). This should be simple but I don't understand why I can't get it right (even though I know where the problem lies). Thank you very much for your help.

---------- EDIT ----------

Ok so I found a way to make it happen, but it seems a little bit "too much" (and still not working properly), I'm guessing there should be a more simple way to achieve what I want.

So what I did is setting a new state errors in the parent component (App) that I use to set the errors in the validate function (they are not set in Form -> handleFormChange() anymore), that I then pass down as a prop to Form. Here are the modified parts :

App component :

function App() {
    //.... Cut down unnecessary parts ....

    const [errors, setErrors] = useState({});

    const validate = (name, value) => {

        const omit = (key, { [key]: _, ...obj }) => obj
        
        switch (name) {
            case 'lastName':
                if (value === '') {
                    setErrors({
                        ...errors,
                        [name]: 'Last name required',
                    });
                }
                else {
                    let newObj = omit('lastName', errors);
                    setErrors(newObj);
                }
                break;

            case 'firstName':
                if (value === '') {
                    setErrors({
                        ...errors,
                        [name]: 'First name required',
                    });
                }
                else {
                    let newObj = omit('firstName', errors);
                    setErrors(newObj);
                }
                break;
        }

    //.... Same as before ....
}

Then here is the new Form component (I'm just showing the handleFormChange function, everything is the same expect for the fact that I pass down the errors prop from App to Form (used in handleFormChange() -> validate()) :

const handleFormChange = (e) => {
    const { name, value } = e.target || e;
        
    const updatedForm = {
        ...form,
        [name]: value,
    };
                
    setForm(updatedForm);
    validate(name, value);
};

So as you can see, validate() still runs in Form but the errors are not set in Form anymore (they are set inside validate(), in the parent component App, in the switch statement).

This almost works as I would like : the error messages show input by input, but only one input at the time. What I mean is, if I type in and blank the first input, it displays the error for this input only, but then if I type in the second input while the first error is set, the first error message disappears but the second input error message displays (and even if I don't trigger the second input's error, it still hides the first error message), and vice versa.

What am I doing wrong ? I would greatly appreciate some advice. Thank you very much

1 Answers

Ok so I found the problem and now everything works as expected : as you type in the first input, if an error occurs for that input it displays it. Then if you type in the second input and an error occurs there, it also displays. And when I have no more errors, it hides them input by input.

What I missed was only better defining values in the validate() switch statement. What I mean is, before I only did if (values === ''), where I needed to do if (values.fieldName === '') to set the errors.

Here is the new validate function in the parent component (App) (note that I also use the name variable everywhere I can to avoid repeating lastName and firstName) :

switch (name) {
            case 'lastName':
                if (values.lastName === '') {
                    setErrors({
                        ...errors,
                        [name]: 'Lastname is required'
                    })
                }
                else  {
                    let newObj = omit(name, errors);
                    setErrors(newObj);
                }
                break;
            case 'firstName':
                if (values.firstName === '') {
                    setErrors({
                        ...errors,
                        [name]: 'Firstname is required'
                    })
                }
                else {
                    let newObj = omit(name, errors);
                    setErrors(newObj);
                }
                break;
        }
    }

And here is the handleFormChange function in Form, where I needed to do validate(name, udpatedForm.values) instead of validate(name, updatedForm) (or as I showed before validate(name, values)) :

const handleFormChange = (e) => {
        const { name, value } = e.target || e;
        
        const updatedForm = {
            ...form,
            values: {
                [name]: value
            },
        };
        
        validate(name, updatedForm.values);
        setForm(updatedForm);
    };

So that was just a silly error on my part... But now everything works great ! I now have my full reusable form / input validation using React context, and if I want to reuse the components, I just have to modify the validate function in the parent component (as well as formInitialValues), and everything is handled within the Form component, holding the context. So no need to touch this anytime soon ! Reusability for life (at least until I decide to add some stuff up maybe).

Note that I still set the errors in the parent component (in validate()), that I then pass down as props to Form. Is anyone as a better suggestion for going about this, please go ahead (I would have liked all the errors to be set in Form but eh, that's what I came up with, it works and meets my needs). Thank you very much

Related