38

I had a class component named <BasicForm> that I used to build forms with. It handles validation and all the form state. It provides all the necessary functions (onChange, onSubmit, etc) to the inputs (rendered as children of BasicForm) via React context.

It works just as intended. The problem is that now that I'm converting it to use React Hooks, I'm having doubts when trying to replicate the following behavior that I did when it was a class:

class BasicForm extends React.Component {

  ...other code...

  touchAllInputsValidateAndSubmit() {

    // CREATE DEEP COPY OF THE STATE'S INPUTS OBJECT
    let inputs = {};
    for (let inputName in this.state.inputs) {
      inputs = Object.assign(inputs, {[inputName]:{...this.state.inputs[inputName]}});
    }

    // TOUCH ALL INPUTS
    for (let inputName in inputs) {
      inputs[inputName].touched = true;
    }

    // UPDATE STATE AND CALL VALIDATION
    this.setState({
      inputs
    }, () => this.validateAllFields());  // <---- SECOND CALLBACK ARGUMENT
  }

  ... more code ...

}

When the user clicks the submit button, BasicForm should 'touch' all inputs and only then call validateAllFields(), because validation errors will only show if an input has been touched. So if the user hasn't touched any, BasicForm needs to make sure to 'touch' every input before calling the validateAllFields() function.

And when I was using classes, the way I did this, was by using the second callback argument on the setState() function as you can see from the code above. And that made sure that validateAllField() only got called after the state update (the one that touches all fields).

But when I try to use that second callback parameter with state hooks useState(), I get this error:

const [inputs, setInputs] = useState({});

... some other code ...

setInputs(auxInputs, () => console.log('Inputs updated!'));

Warning: State updates from the useState() and useReducer() Hooks don't support the second callback argument. To execute a side effect after rendering, declare it in the component body with useEffect().

So, according to the error message above, I'm trying to do this with the useEffect() hook. But this makes me a little bit confused, because as far as I know, useEffect() is not based on state updates, but in render execution. It executes after every render. And I know React can queue some state updates before re-rendering, so I feel like I don't have full control of exactly when my useEffect() hook will be executed as I did have when I was using classes and the setState() second callback argument.

What I got so far is (it seems to be working):

function BasicForm(props) {

  const [inputs, setInputs] = useState({});
  const [submitted, setSubmitted] = useState(false);

  ... other code ...

  function touchAllInputsValidateAndSubmit() {
    const shouldSubmit = true;

    // CREATE DEEP COPY OF THE STATE'S INPUTS OBJECT
    let auxInputs = {};
    for (let inputName in inputs) {
      auxInputs = Object.assign(auxInputs, {[inputName]:{...inputs[inputName]}});
    }

    // TOUCH ALL INPUTS
    for (let inputName in auxInputs) {
      auxInputs[inputName].touched = true;
    }

    // UPDATE STATE
    setInputs(auxInputs);
    setSubmitted(true);
  }

  // EFFECT HOOK TO CALL VALIDATE ALL WHEN SUBMITTED = 'TRUE'
  useEffect(() => {
    if (submitted) {
      validateAllFields();
    }
    setSubmitted(false);
  });

  ... some more code ...

}

I'm using the useEffect() hook to call the validateAllFields() function. And since useEffect() is executed on every render I needed a way to know when to call validateAllFields() since I don't want it on every render. Thus, I created the submitted state variable so I can know when I need that effect.

Is this a good solution? What other possible solutions you might think of? It just feels really weird.

Imagine that validateAllFields() is a function that CANNOT be called twice under no circunstances. How do I know that on the next render my submitted state will be already 'false' 100% sure?

Can I rely on React performing every queued state update before the next render? Is this guaranteed?

cbdeveloper
  • 27,898
  • 37
  • 155
  • 336
  • 4
    Why don't you have your `useEffect()` watching for `[submitted]`? E.g. `useEffect(() => {}, [submitted])`? – Colin Ricardo Mar 25 '19 at 19:22
  • Will do that. I've read the documentation before but had totally forgotten about that. Thanks! Do you think that solves the problem? I mean, it will no longer matter when React will apply the state update for the `submitted` variable, my effect will only execute once because it listens for changes in that variable? – cbdeveloper Mar 25 '19 at 19:30
  • To your question: yes that's right. It should solve the problem, and I expanded a bit in my answer. – Colin Ricardo Mar 25 '19 at 19:33
  • 3
    Maybe this custom hook is helpful to add a callback function after useState: https://github.com/the-road-to-learn-react/use-state-with-callback – Robin Wieruch May 31 '19 at 11:21
  • @RobinWieruch thanks, Robin. That's a great use for a custom hook! I know your website! It helped me a lot! – cbdeveloper May 31 '19 at 14:37

2 Answers2

17

I encountered something like this recently (SO question here), and it seems like what you've come up with is a decent approach.

You can add an arg to useEffect() that should do what you want:

e.g.

useEffect(() => { ... }, [submitted])

to watch for changes in submitted.

Another approach could be to modify hooks to use a callback, something like:

import React, { useState, useCallback } from 'react';

const useStateful = initial => {
  const [value, setValue] = useState(initial);
  return {
    value,
    setValue
  };
};

const useSetState = initialValue => {
  const { value, setValue } = useStateful(initialValue);
  return {
    setState: useCallback(v => {
      return setValue(oldValue => ({
        ...oldValue,
        ...(typeof v === 'function' ? v(oldValue) : v)
      }));
    }, []),
    state: value
  };
};

In this way you can emulate the behavior of the 'classic' setState().

Colin Ricardo
  • 16,488
  • 11
  • 47
  • 80
  • Thanks a lot for your help! I think I'll go with the approach you've mentioned on your comment on my question (second argument to watch a variable on `useEffect()`). I'll try that tomorrow and will let you know if it worked. You can write an answer suggesting that approach. – cbdeveloper Mar 25 '19 at 19:42
  • I tried to watch for the `submitted` variable on the `useEffect()` hook but it didn't solve my problem. It turns out that if you're calling a function inside `useEffect()` and that functions uses `props` or `state` (which my functions does) you need to add that function as a dependency for the `useEffect()` as well. [Source] (https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies). It kind of worked, but got too complicated and I felt it was a bit 'hacky' for what I was trying to do. Also I wasn't sure of how many times my function was being executed – cbdeveloper Mar 26 '19 at 14:37
  • Thanks a lot for your help. I'll post the solution I've came up with, that is working well so far. – cbdeveloper Mar 26 '19 at 14:38
  • Could you explain this two lines `return setValue(oldValue => ({ ...oldValue, ...(typeof v === 'function' ? v(oldValue) : v) })); ` What oldValue holds in the first time? And why you do this condition typeof v === 'functon'? – thodwris Sep 15 '21 at 04:06
3

I have tried to solve it using the useEffect() hook but it didn't quite solve my problem. It kind of worked, but I ended up finding it a little too complicated for a simple task like that and I also wasn't feeling sure enough about how many times my function was being executed, and if it was being executed after the state change of not.

The docs on useEffect() mention some use cases for the effect hook and none of them are the use that I was trying to do.

useEffect API reference

Using the effect hook

I got rid of the useEffect() hook completely and made use of the functional form of the setState((prevState) => {...}) function that assures that you'll get a current version of your state when you use it like that. So the code sequence became the following:

// ==========================================================================
// FUNCTION TO HANDLE ON SUBMIT
// ==========================================================================

function onSubmit(event){
  event.preventDefault();
  touchAllInputsValidateAndSubmit();
  return;
}
// ==========================================================================
// FUNCTION TO TOUCH ALL INPUTS WHEN BEGIN SUBMITING
// ==========================================================================

function touchAllInputsValidateAndSubmit() {

  let auxInputs = {};
  const shouldSubmit = true;

  setInputs((prevState) => {

    // CREATE DEEP COPY OF THE STATE'S INPUTS OBJECT
    for (let inputName in prevState) {
      auxInputs = Object.assign(auxInputs, {[inputName]:{...prevState[inputName]}});
    }

    // TOUCH ALL INPUTS
    for (let inputName in auxInputs) {
      auxInputs[inputName].touched = true;
    }

    return({
      ...auxInputs
    });

  });

  validateAllFields(shouldSubmit);

}
// ==========================================================================
// FUNCTION TO VALIDATE ALL INPUT FIELDS
// ==========================================================================

function validateAllFields(shouldSubmit = false) {

  // CREATE DEEP COPY OF THE STATE'S INPUTS OBJECT
  let auxInputs = {};

  setInputs((prevState) => {

    // CREATE DEEP COPY OF THE STATE'S INPUTS OBJECT
    for (let inputName in prevState) {
      auxInputs =
          Object.assign(auxInputs, {[inputName]:{...prevState[inputName]}});
    }

    // ... all the validation code goes here

    return auxInputs; // RETURNS THE UPDATED STATE

  }); // END OF SETINPUTS

  if (shouldSubmit) {
    checkValidationAndSubmit();
  }

}

See from the validationAllFields() declaration that I'm performing all my code for that function inside a call of setInputs( (prevState) => {...}) and that makes sure that I'll be working on an updated current version of my inputs state, i.e: I'm sure that all inputs have been touched by the touchAllInputsValidateAndSubmit() because I'm inside the setInputs() with the functional argument form.

// ==========================================================================
// FUNCTION TO CHECK VALIDATION BEFORE CALLING SUBMITACTION
// ==========================================================================

function checkValidationAndSubmit() {

  let valid = true;

  // THIS IS JUST TO MAKE SURE IT GETS THE MOST RECENT STATE VERSION
  setInputs((prevState) => {

    for (let inputName in prevState) {
      if (inputs[inputName].valid === false) {
        valid = false;
      }
    }
    if (valid) {
      props.submitAction(prevState);
    }

    return prevState;

  });
}

See that I use that same pattern of the setState() with functional argument call inside the checkValidationAndSubmit() function. In there, I also need to make sure that I'm get the current, validated state before I can submit.

This is working without issues so far.

bdkopen
  • 494
  • 1
  • 6
  • 16
cbdeveloper
  • 27,898
  • 37
  • 155
  • 336