reactjstypescript

React function in child component can't see state when called from another component


I have a Parent component containing a Child component.

The Child holds multiple form elements, along with their state and functions to validate them.
The Parent has a form button that, when clicked, calls a validation function that is implemented in the Child.

The problem is that, when the function is called from the Parent on submit, the function can't "see" current values of the state variables (it sees the initial value).
However, when the function is called from the Child, it can see the current value as expected.

Here is the implementation of the Parent:

type ValidatorRunner = () => boolean;

const Parent = () => {

  const [ childValidator, setChildValidator ] = useState<ValidatorRunner>();

  function validateChild(): boolean {
    if (childValidator) return childValidator();
    else return false;
  }

  const doSubmit = async (e: SyntheticEvent) => {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);          
    const formJson = Object.fromEntries(formData.entries());

    if (validateChild()) {
      console.log("[Parent]: Good to send data:\n" + JSON.stringify(formJson));
    } else {
      console.log("[Parent]: Can't send data (data not valid)");
    }
  };

  return (
    <div>
      <form onSubmit={ doSubmit }>
        <div>Parent</div>
        <div><Child validatorSetter={ setChildValidator }/></div>
        <div><button type='submit'>Submit</button></div>
      </form>
    </div>
  );  
};

Here is the Child:

interface ChildProps {
  text?: string,
  validatorSetter?: (validator: () => ValidatorRunner) => void
}

const Child = ({text, validatorSetter}: ChildProps) => {

  const [ textValue, setTextValue] = useState(text ?? "");
  const [ textFeedback, setTextFeedback] = useState("");

  useEffect(() => {
    if (validatorSetter) {
      validatorSetter(() => validate);
    };
  }, [validatorSetter]);
  
  const validate = (): boolean => {
    console.log("[Child]: validate " + textValue);
    let isValidText: boolean = validateText(textValue);
    return isValidText;
  }

  const validateText = (data: string): boolean => {
    if (data.length === 0) { 
      setTextFeedback("The text can't be empty");
      return false; 
    }
    else {
      setTextFeedback("");
      return true;
    }
  };

  const doChange = (e: ChangeEvent<HTMLInputElement>) => { 
    setTextValue(e.target.value);
    validateText(e.target.value);
  };

  return (
    <div style={{ border: '1px solid' }}>
      <label htmlFor="text">Text: </label>
      <input id="text" name='text' type='text' maxLength={ 16 } onChange={ doChange } value={ textValue }/>
      <button type="button" onClick={ validate }>?</button>
      <label> { textFeedback } </label>
    </div>
  );
};

Clicking [?] (in Child) calls Child.validate(), which prints the current value of Child.textValue to the console as expected.
Clicking [Submit] (in Parent) calls Child.validate(), which prints the initial value of Child.textValue.

How can I make that work? I have tried other solutions like useRef, useImperativeHandle, etc, but couldn't make them work in this scenario.


Solution

  • IMHO it is more common to use useImperativeHandle in such case:

    type ValidatorRunner = () => boolean;
    
    interface ChildProps {
      text?: string;
      validatorSetter?: (validator: () => ValidatorRunner) => void;
    }
    interface ChildHandle {
      validate: Function;
    }
    
    const Child = React.forwardRef<ChildHandle, ChildProps>(({ text }, ref) => {
      const [textValue, setTextValue] = useState(text ?? '');
      const [textFeedback, setTextFeedback] = useState('');
      const validate = (): boolean => {
        console.log('[Child]: validate ' + textValue);
        let isValidText: boolean = validateText(textValue);
        return isValidText;
      };
      React.useImperativeHandle(ref, () => {
        return {
          validate() {
            return validate();
          },
        };
      });
    
      const validateText = (data: string): boolean => {
        if (data.length === 0) {
          setTextFeedback("The text can't be empty");
          return false;
        } else {
          setTextFeedback('');
          return true;
        }
      };
    
      const doChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        setTextValue(e.target.value);
        validateText(e.target.value);
      };
    
      return (
        <div style={{ border: '1px solid' }}>
          <label htmlFor="text">Text: </label>
          <input
            id="text"
            name="text"
            type="text"
            maxLength={16}
            onChange={doChange}
            value={textValue}
          />
          <button type="button" onClick={validate}>
            ?
          </button>
          <label> {textFeedback} </label>
        </div>
      );
    });
    
    const Parent = () => {
      const childRef = React.useRef<ChildHandle>(null);
    
      const doSubmit = async (e: React.SyntheticEvent) => {
        e.preventDefault();
        const formData = new FormData(e.target as HTMLFormElement);
        const formJson = Object.fromEntries(formData.entries());
    
        if (childRef?.current?.validate()) {
          console.log('[Parent]: Good to send data:\n' + JSON.stringify(formJson));
        } else {
          console.log("[Parent]: Can't send data (data not valid)");
        }
      };
    
      return (
        <div>
          <form onSubmit={doSubmit}>
            <div>Parent</div>
            <div>
              <Child ref={childRef} />
            </div>
            <div>
              <button type="submit">Submit</button>
            </div>
          </form>
        </div>
      );
    };
    

    You mentioned you had same problems with this approach but it was probably due to using wrong dependencies for the useImperativeHandle.

    This way you don't need additional state variable in parent component where you were storing the validator for example.