setState callback hell

I’ve been reading a number of tutorials on using setState with callback functions. I’ve read the react documentation, and I’ve read & reread Dan Abramov’s tweets which most of the tutorials are based on (a counter increment example).

This article on FCC is actually one of teh better ones I’ve read regarding abstracting setState functions and using callbacks.

However I still have a unique situation that others must be encountering …

I have a quiz app with several components of the Quiz:

  1. Question count (at the top, says, “question 1 of 5”.
  2. The question itself
  3. The answer optoins
  4. Control buttons (prev, next, show unanswered/score, review)
  5. A feedback area, displays a discussion of the question if you click a butotn int he control area

Some state updates occur when you choose a question that allows teh following things:

  • the discussion is updated if the user chooses to reveal it
  • the radio button has a class applied making it look like a fancy green checkmark
  • the answer is recorded

Other state updates occur when the next or prev buttons are pressed
-the question counter is advanced

  • new question & answer options are updated
  • if the question was previously answered, it remains answered (so green check mark & discussion is loaded from before)
  • if it is not previously answered, the discussion is cleared until the next answer is clicked.

I’m able to get some of these state updates to occur synchronously using callbacks, but I can’t get all of them to occur at the same time. If I have 5 different state updates that all depend on the prior one, do I have to setup 5 embedded callbacks?

I can’t do it all in one set State call because the conditions vary depending on whether it’s the first question, last question, unanswerd questoins remain, question already answered once, etc.

Here is an example of the groups of functions I’m calling in one case:

        this.setState(incrementCounter)
        this.setState(incrementQuestionId)
        this.setOptionSelected(this.state.counter)
        this.setNextQuestion(this.state.counter, this.state.questionId)
        this.setNextPrevButtons()

I can provide the details of those, but the latter 3 all have setState functions within.

It’s pretty hard to give you a detailed answer without seeing any code, so this will be a more broad answer.

setState may be async, this means that multiple setState called right after the other can be batched into one:
For example

this.setState(incrementCounter)
this.setState(incrementQuestionId)

Internally may be batched into a single setState.

Consider also that using a callback in setState is equivalent to using componentDidUpdate() lifecycle method, and as always when using this method you should be extra careful to avoid useless re-renders.

I can group those two without trouble I just haven’t consolidated the code yet. I could probably even get rid of questionId as state because it’s always 1 more than counter due to 0 indexing if arrays.

It’s the rest I can’t group with the count unless I embed all the functionality in one huge if/then statement. I can do that, but I wanted to refactor my code and so I created these smaller sets of testing for each similar function like setting the next question to display, which could be navigated to by using next or previous or “show unanswered”

I’m not sure you need these three, it seems you’re overthinking a bit. setState() will trigger re-render with new state, right? So why are you setting next question, when you can just render <Question id={this.state.questionId} />?

SO initially I was building out functionality one thing at a time, and I created functions for these bundles of state changes to keep the logic clear in my mind. Only when I got each piece working on it’s own did I start to run into the issues I’m having. The #1 issue that’s obvious is that the Previous button doesn’t become enabled when clicking from question 1 to question 2 (it’s disabled for Q 1). I believe this is because the “setNextPrevButtons” is called and finishes before the increment or decrement counter function is called.

set OptionSelected
Check to see if the question has been previously selected when scrolling forward and backwards through the quiz. If it has, the green checkmark will show. There may be a more concise way to do this function of course ,so I’ll take a look at that.

  setOptionSelected (counter) {
    console.log("keeps previously answered questions checked when scrolling")
    if (this.state.selectedAnswers[counter]) {
      this.setState(prevState => ({
        behavior: {
          ...prevState.behavior,
          optionSelected: true
        }
      }))
    } else {
      this.setState(prevState => ({
        behavior: {
          ...prevState.behavior,
          optionSelected: false
        }
      }))
    }
  }

setNextQuestion
retrieves the question & answer set for the next question ( which could be arrived at using previous or next or unanswered buttons). I could probably bundle this with the counter & question ID.

setNextQuestion (counter, questionId) {
    console.log("Setting Next Question")
    this.setState(prevState => ({
      // counter:       counter,
      // questionId:    questionId,
      question:      quizQuestions[prevState.counter].question,
      answerOptions: quizQuestions[prevState.counter].answers,
      behavior: {
        ...prevState.behavior,
        reveal: false
      },
      discussion:    {
        correct:    false,
        discussion: "",
        label: ""
      }
    }))
  }

setNextPrevButtons
…this turns the next & prev buttons to enabled or disabled depending on if it is the first question, the last question or something in the middle.

So I could bundle these into one big state update I think, but since some of the items depend on conditionals, I still need a separate scope for some setStates.

Example:

setNextPrevButtons (){
    if ( this.state.counter===0){
      this.setState({
        gui: {
          showPrev:false,
          showNext: true
        }
      })
    } else if ( this.state.counter===quizQuestions.length-1) {
      this.setState({
        gui: {
          showPrev:true,
          showNext: false
        }
      })
    } else {
      this.setState({
        gui: {
          showPrev:true,
          showNext: true
        }
      })
    }

The second example setNextPrevButtons method could make use of the functional version of setState. This will guarantee you are always referencing the latest value of the state’s counter property.

setNextPrevButtons() {
  this.setState(({ counter }) => {
    const showPrev = counter !== 0;
    const showNext = counter !== quizQuestions.length - 1;
    return { gui: { showPrev, showNext } };
  });
}

Thanks Randell,

so if counter is not equal to zero, Prev will be True
if counter not equal to quiz length - 1 (ie at end) show next will be true.

I havn’t used that syntax before but I like it…not sure if that’s basic JS or ES6 or something else. I’ll try using that.

Any thoughts about the rest? I probably need to go back to paper and write out a flow chart to get it out of my head. I keep adding “The next thing” that looks like it would smooth out the user experience.

Hey this works!! Thanks for spotting this issue. That was very hard for me to trouble shoot, I appreciate your help.

Now I have to sort out what I broke while trying to fix this

@camperextraordinaire @snigo @Marmiz
Thanks for your questions & suggestions. I went through and refactored everything and was able to simplify my rendering for <Quiz /> by eliminating redundant code.

Here is my updated and refactored update method, regardless of which button is pressed. Thanks especially to @snigo as you made me think about why i had so many state elements, when updating the counter should be sufficient to re-render appropriate question & answers.

 handleUpdate (increment) {
    const counter = this.state.counter + increment
    const showPrev = counter !== 0;
    const showNext = counter !== quizQuestions.length - 1;
    const optionSelected = (this.state.selectedAnswers[counter]>0)

    this.setState(prevState => ({
          optionSelected,
          counter,
          gui:        {
            showPrev,
            showNext
          },
          // unansweredQuestions,
          behavior:   {
            ...prevState.behavior,
            optionSelected: optionSelected,
            reveal: false
          },
          discussion: {
            correct:    false,
            discussion: "",
            label:      ""
          }
        }
      )
    )
  }
1 Like