10

I'm making a primitive quiz app with 3 questions so far, all true or false. In my handleContinue method there is a call to push the users input from a radio form into the userAnswers array. It works fine for the first run of handleContinue, after that it throws an error: Uncaught TypeError: this.state.userAnswers.push is not a function(…)

import React from "react"

export default class Questions extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      questionNumber: 1,
      userAnswers: [],
      value: ''
    }

    this.handleContinue = this.handleContinue.bind(this)
    this.handleChange = this.handleChange.bind(this)
  }

  //when Continue button is clicked
  handleContinue() {
    this.setState({
      //this push function throws error on 2nd go round
      userAnswers: this.state.userAnswers.push(this.state.value),
      questionNumber: this.state.questionNumber + 1
    //callback function for synchronicity
    }, () => {
      if (this.state.questionNumber > 3) {
        this.props.changeHeader(this.state.userAnswers.toString())
        this.props.unMount()
      } else {
        this.props.changeHeader("Question " + this.state.questionNumber)
      }
    })
    console.log(this.state.userAnswers)
  }

  handleChange(event) {
    this.setState({
      value: event.target.value
    })
  }

  render() {
    const questions = [
      "Blargh?",
      "blah blah blah?",
      "how many dogs?"
    ]
    return (
      <div class="container-fluid text-center">
        <h1>{questions[this.state.questionNumber - 1]}</h1>
        <div class="radio">
          <label class="radio-inline">
            <input type="radio" class="form-control" name="trueFalse" value="true" 
            onChange={this.handleChange}/>True
          </label><br/><br/>
          <label class="radio-inline">
            <input type="radio" class="form-control" name="trueFalse" value="false" 
            onChange={this.handleChange}/>False
          </label>
          <hr/>
          <button type="button" class="btn btn-primary" 
          onClick={this.handleContinue}>Continue</button>
        </div>
      </div>
    )
  }
}
Jackson Lenhart
  • 630
  • 3
  • 7
  • 19

6 Answers6

22

Do not modify state directly! In general, try to avoid mutation.

Array.prototype.push() mutates the array in-place. So essentially, when you push to an array inside setState, you mutate the original state by using push. And since push returns the new array length instead of the actual array, you're setting this.state.userAnswers to a numerical value, and this is why you're getting Uncaught TypeError: this.state.userAnswers.push is not a function(…) on the second run, because you can't push to a number.

You need to use Array.prototype.concat() instead. It doesn't mutate the original array, and returns a new array with the new concatenated elements. This is what you want to do inside setState. Your code should look something like this:

this.setState({
  userAnswers: this.state.userAnswers.concat(this.state.value),
  questionNumber: this.state.questionNumber + 1
}
p4sh4
  • 3,292
  • 1
  • 20
  • 33
6

Array.push does not returns the new array. try using

this.state.userAnswers.concat([this.state.value])

this will return new userAnswers array

References: array push and array concat

duwalanise
  • 1,312
  • 1
  • 14
  • 24
  • 3
    This answer solves the problem but doesn't really explain what is happening. I recommend to take a look at my answer that explains the issue in more detail. – p4sh4 Dec 09 '16 at 04:21
6

You should treat the state object as immutable, however you need to re-create the array so its pointing to a new object, set the new item, then reset the state.

 handleContinue() {
    var newState = this.state.userAnswers.slice();
    newState.push(this.state.value);

    this.setState({
      //this push function throws error on 2nd go round
      userAnswers: newState,
      questionNumber: this.state.questionNumber + 1
    //callback function for synchronicity
    }, () => {
      if (this.state.questionNumber > 3) {
        this.props.changeHeader(this.state.userAnswers.toString())
        this.props.unMount()
      } else {
        this.props.changeHeader("Question " + this.state.questionNumber)
      }
    })
    console.log(this.state.userAnswers)
  }

Another alternative to the above solution is to use .concat(), since its returns a new array itself. Its equivalent to creating a new variable but is a much shorter code.

this.setState({
  userAnswers: this.state.userAnswers.concat(this.state.value),
  questionNumber: this.state.questionNumber + 1
}
Shubham Khatri
  • 270,417
  • 55
  • 406
  • 400
  • This works fine, but it doesn't seem optimal to be declaring a new variable each time `handleContinue` is run. Though I could be wrong, thoughts? – Jackson Lenhart Dec 09 '16 at 03:49
  • 2
    You are right, it does cause waste of memory but its better than the potential race conditions that may arise if the state is mutated directly – Shubham Khatri Dec 09 '16 at 04:00
  • 1
    This is not a very good answer. While it technically works, it's much shorter and clearer to just use `concat`, and [the docs](https://facebook.github.io/react/docs/optimizing-performance.html#the-power-of-not-mutating-data) recommend doing so. – p4sh4 Dec 09 '16 at 04:13
  • This does not work fine all the time. I just had an issue where the same approach was working in one component while not working on another one with same prop. It is better to use `concat` instead. – anshuraj Aug 22 '18 at 09:52
4

The recommended approach in later React versions is to use an updater function when modifying states to prevent race conditions:

this.setState(prevState => ({
  userAnswers: [...prevState.userAnswers, this.state.value] 
}));
Hemadri Dasari
  • 32,666
  • 37
  • 119
  • 162
3

I have found a solution. This shoud work for splice and others too. Lets say that I have a state which is an array of cars:

this.state = {
  cars: ['BMW','AUDI','mercedes']
};
this.addState = this.addState.bind(this);

Now, addState is the methnod that i will use to add new items to my array. This should look like this:

  addState(){

let arr = this.state.cars;
arr.push('skoda');
this.setState({cars: arr});
}

I have found this solution thanks to duwalanise. All I had to do was to return the new array in order to push new items. I was facing this kind of issue for a lot of time. I will try more functions to see if it really works for all functions that normally won't. If anyone have a better idea how to achieve this with a cleaner code, please feel free to reply to my post.

Ciprian
  • 353
  • 1
  • 2
  • 6
2

The correct way to mutate your state when you want to push something to it is to do the following. Let's say we have defined a state as such:

const [state, setState] = useState([])

Now we want to push the following object into the state array. We use the concat method to achieve this operation as such:

let object = {a: '1', b:'2', c:'3'}

Now to push this object into the state array, you do the following:

setState(state => state.concat(object))

You will see that your state is populated with the object. The reason why concat works but push doesn't is because of the following

Array.prototype.push() adds an element into original array and returns an integer which is its new array length.



Array.prototype.concat() returns a new array with concatenated element without even touching in original array. It's a shallow copy.
PrinceMoMo
  • 69
  • 2