React form useState object doesn't work as I want

Viewed 77

I have a problem with a React app. I have a form with two inputs, and when I submit the form with empty inputs, it should render an error message in each of them. The problem is that it doesn't show for the first input. How can I fix it to display an error in each of those? The implementation is in useForm.js.

FormUI image:
FormUI image

My code:

Form.js

const Form = () => {
  
const formLogin = () => {
  console.log("Callback function when form is submitted!");
  console.log("Form Values ", values);
}

const {handleChange, values, errors, handleSubmit} = useForm(formLogin);


  return (
    <Wrapper>
      <form onSubmit={handleSubmit}>

        <div className="govgr-form-group gap-bottom">
          <label className="govgr-label govgr-!-font-weight-bold" htmlFor="code">Code*</label>
          {errors.code && <p className="govgr-error-message"><span className="govgr-visually-hidden">Λάθος:</span>{errors.code}</p>}
          <input className={`govgr-input govgr-!-width-three-quarter ${errors.code ? 'govgr-error-input' : ''}`} id="code" name="code" type="text" onChange={handleChange} />
        </div>

        <fieldset>
          <div className="govgr-form-group">
            <label className="govgr-label govgr-!-font-weight-bold" htmlFor="first">Name*</label>
            {errors.first && <p className="govgr-error-message"><span className="govgr-visually-hidden">Λάθος:</span>{errors.first}</p>}
            <input className={`govgr-input govgr-!-width-three-quarter ${errors.first ? 'govgr-error-input' : ''}`} id="first" name="first" type="text" onChange={handleChange} />
          </div>

         
        </fieldset>

        <button type="submit" className="govgr-btn govgr-btn-primary btn-center">Save</button>

      </form>
    </Wrapper>
  );
};

export default Form;

useForm.js:

const useForm = (callback) => {
  
  const [values, setValues] = useState({});
  
  const [errors, setErrors] = useState({});

  const validate = (event, name, value) => {
    
    event.persist();

    switch (name) {
      case "code":
        if (value.trim() === "" || value.trim() === null) {
          setErrors({
            ...errors,
            code: "Code is required",
          });
        } else {
          let newObj = omit(errors, "code");
          setErrors(newObj);
        }
        break;

      case "first":
        if (value.trim() === "" || value.trim() === null) {
          setErrors({
            ...errors,
            first: "Name is required",
          });
        } else {
          let newObj = omit(errors, "first");
          setErrors(newObj);
        }
        break;

        

      default:
        break;
    }
  };

  
  const handleChange = (event) => {
    event.persist();

    let name = event.target.name;
    let val = event.target.value;

    validate(event, name, val);

    setValues({
      ...values,
      [name]: val,
    });
  };

  const handleSubmit = (event) => {
    if (event) event.preventDefault();

    if (
      Object.keys(errors).length === 0 &&
      Object.keys(values).length !== 0 &&
      values.code &&
      values.first 
    ) {
      callback();
    } else {
      if (!values.code) {
        setErrors({
          ...errors,
          code: "Code is required.",
        });
      }
      if (!values.first) {
        setErrors({
          ...errors,
          first: "Name is required.",
        });
      }
    }
  };

  return {
    values,
    errors,
    handleChange,
    handleSubmit
  };
};

export default useForm;
1 Answers

You can simplify your code by having only a single validation routine. You can fix the error you mention, having only a single error at a time, by using the current state as passed into setState by the framework. The construct for this is

setState(e => /* whatever you want to return relative to e */);

Ultimately, the error you're seeing is because the component, and its code, is rerendered after every change of state (but they are batched for performance reasons), so your code is only seeing an older version of the state when you access it directly. If you want the actual current state when changing it, it's always best to have the framework pass it directly to the change-state function. If you always follow this pattern, you will rarely have any problems with the react state model.

The hard problem however isn't any of this. The hard problem is the way you're removing errors. First, from a UI perspective, it's a little inconsistent to only show a field as an error when it is edited, because the form is erroneous when it's first shown. But, ignoring that, the underlying technical problem is removing a property from an object. Normally we would use arrays rather than objects when we need to do this, or we would use underscore's omit function. I see in your code you call a function called omit, but you don't have an explanation as to what that is. In your case though, this can be easily solved by never removing the property. Instead, have each as an object with a valid flag. This would allow you to remove the switch statement completely.

You could go further and roll the value in there too, making a complete single state for each field, but I won't demonstrate that here.

I haven't tested this, so there may be a typo or two lurking in here.

const useForm = (callback) => {
  const [values, setValues] = useState({code: '', first: ''});
  const [errors, setErrors] = useState({first: {message: "Name is required", valid: true}, code: {message: "Code is required", valid: true}});

  const isEmpty = val => val.trim() === "" || val.trim() === null);

  const validate = (name, val) => {
    setErrors(e => ({...e, [name]: {...e[name], valid: isEmpty(val)}}))
    return errors[name].valid;
  };
  
  const handleChange = (event) => {
    let name = event.target.name;
    let val = event.target.value;
    setValues({...values, [name]: val});
    validate(name, val);
  };

  const handleSubmit = event => {
    if (event) event.preventDefault();

    const valid = validate('code', values.code) && validate('first', values.first);

    if ( valid ) {
      callback();
    } 
  };

  return {
    values,
    errors,
    handleChange,
    handleSubmit
  };
};

export default useForm;

And, you'd have to change your HTML template slightly:

{!errors.first.valid && <p className="govgr-error-message"><span className="govgr-visually-hidden">Λάθος:</span>{errors.first.message}</p>}

I realise that this new validate function is a huge departure from what you had, but based on your own code I think you can handle it. Basically, it's just using destructuring and variable property accessors. This looks like its well within your capabilities to understand, but feel free to ask more questions if any of it is confusing or doesn't actually work :)

Related