3

In this componentDidUpdate method, after performing setState to set quotes to what's returned from the fetch, I have to use the callback to perform setState a second time to set randomQuoteIndex to the result of calling randomQuoteIndex, which relies on this.state.quotes.length, i.e.:

componentDidMount() {
    fetch('https://gist.githubusercontent.com/nataliecardot/0ca0878d2f0c4210e2ed87a5f6947ec7/raw/1802a693d02ea086817e46a42413c0df4c077e3b/quotes.json')
      // Takes a JSON response string and parses it into JS object
      .then(response => response.json())
      // state is set to quotes: quotes due to destructuring
      // Using setState callback since setState is asynchronous and need to make sure quotes is loaded before setting the randomQuoteIndex state since it depends on it
      .then(quotes => this.setState({ quotes }, () => {
        this.setState({
          randomQuoteIndex: this.randomQuoteIndex(),
          isDoneFetching: true
        })
      }))
  }

Why doesn't the code below work? Based on the selected answer to this question, I'm under the impression that the second item in setState won't be applied until after state is set for the first item. If I try this, I get an error "TypeError: Cannot read property 'quote' of undefined." (I read that setState is asynchronous and about when to use the callback but I'm having a hard time understanding what I read/how it applies in this case.)

  componentDidMount() {
    fetch('https://gist.githubusercontent.com/nataliecardot/0ca0878d2f0c4210e2ed87a5f6947ec7/raw/1802a693d02ea086817e46a42413c0df4c077e3b/quotes.json')
      // Takes a JSON response string and parses it into JS object
      .then(response => response.json())
      // Using setState callback since setState is asynchronous and need to make sure quotes is loaded before setting the randomQuoteIndex state since it depends on it
      .then(quotes => this.setState({
          quotes,
          randomQuoteIndex: this.randomQuoteIndex(),
          isDoneFetching: true
        }));
  }

Here's the full component code (the working version):

import React, { Component } from 'react';
import './App.css';
import { random } from 'lodash';
import Button from './components/Button';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      quotes: [],
      randomQuoteIndex: null,
      isDoneFetching: false
    }
  }

  componentDidMount() {
    fetch('https://gist.githubusercontent.com/nataliecardot/0ca0878d2f0c4210e2ed87a5f6947ec7/raw/1802a693d02ea086817e46a42413c0df4c077e3b/quotes.json')
      // Takes a JSON response string and parses it into JS object
      .then(response => response.json())
      // state is set to quotes: quotes due to destructuring
      // Using setState callback since setState is asynchronous and need to make sure quotes is loaded before setting the randomQuoteIndex state since it depends on it
      .then(quotes => this.setState({ quotes }, () => {
        this.setState({
          randomQuoteIndex: this.randomQuoteIndex(),
          isDoneFetching: true
        })
      }))
  }

  get randomQuote() {
    return this.state.quotes[this.state.randomQuoteIndex];
  }

  randomQuoteIndex() {
    return random(0, this.state.quotes.length - 1);
  }

  render() {
    return (
      <div className="App" id="quote-box">
        {this.state.isDoneFetching ? this.randomQuote.quote : 'Loading...'}
        <Button
          buttonDisplayName="Next"
          clickHandler={this.blah}
        />
      </div>
    );
  }
}

export default App;
nCardot
  • 5,992
  • 6
  • 47
  • 83
  • Well, the callback ensures that your previous state has been applied already. The question is, why should your `randomQuoteIndex` rely on a state property rather than retrieving an array as an argument, which would be a much more reusable concept (it doesn't even need to be inside the component anymore at that time) – Icepickle Jul 08 '19 at 22:34
  • where error is coming from? is it happening in `render()`? – skyboyer Jul 08 '19 at 22:36
  • 1
    I also don't really get why you would rely on a getter property to retrieve a quote, but then ask for the quote of the quote :) – Icepickle Jul 08 '19 at 22:36
  • My guess is that the culprit is the `this` reference in your callback. To verify, can you change `randomQuoteIndex: this.randomQuoteIndex()` to something else like `randomQuoteIndex: Date.now()` and see if that produces the same error? – Arash Motamedi Jul 08 '19 at 22:37
  • Can you try using prevState on your quotes field, like in the answer to this question https://stackoverflow.com/questions/43638938/updating-an-object-with-setstate-in-react ? If we want to keep fields on an object in setState, we need to either use prevState or a spread operator. – CyclopeanCity Jul 08 '19 at 22:41
  • Arash -- the setState with the callback works. I'm just wondering why I can't have a single setState rather than one with a callback. – nCardot Jul 08 '19 at 23:12
  • Icepickle -- yes since I'm not using a private variable using a getter function doesn't make that much sense (it was suggested in a tutorial since you don't have to bind it but I'll change it to a regular function). As for the randomQuoteIndex, not sure I get what you're saying but I have to use the length of the fetched quote list because the number of quotes in the list might change. – nCardot Jul 08 '19 at 23:15

2 Answers2

1

It's not that setState is asynchronous, it's a result of randomQuoteIndex being called before the state is set. This would be the case with or without asynchronous updating of state. Consider this slightly refactored version of componentDidMount:

  componentDidMount() {
    fetch('https://gist.githubusercontent.com/nataliecardot/0ca0878d2f0c4210e2ed87a5f6947ec7/raw/1802a693d02ea086817e46a42413c0df4c077e3b/quotes.json')
      .then(response => response.json())
      .then(quotes => {
        const newState = {
          randomQuoteIndex: this.randomQuoteIndex(),
          isDoneFetching: true,
          quotes
        }
        this.setState(newState)
      })
  }

This is functionally exactly the same as the version you posted in your question. Hopefully this highlights that this.randomQuoteIndex() is evaluated before this.setState(newState), and because setState has not been called yet, there is no state, which randomQuoteIndex relies on. When calling setState the argument must be evaluated before it can be passed to setState, so synchronous or not, the update has not happed at the point that randomQuoteIndex is being called.

The easy way to fix this is to make randomQuoteIndex take the list of quotes as an argument rather than pulling it out of the component state. Rewritten, the pair of methods might look like:

  componentDidMount() {
    fetch('https://gist.githubusercontent.com/nataliecardot/0ca0878d2f0c4210e2ed87a5f6947ec7/raw/1802a693d02ea086817e46a42413c0df4c077e3b/quotes.json')
      .then(response => response.json())
      .then(quotes => this.setState({
          quotes,
          randomQuoteIndex: this.randomQuoteIndex(quotes),
          isDoneFetching: true
        }));
  }

  randomQuoteIndex(quotes) {
    return random(0, quotes.length - 1);
  }

Which only requires that setState be called once, and (potentially) saves you a re-render.

Ryan Jenkins
  • 878
  • 8
  • 16
  • Your suggestion worked, but I don't understand how -- in both this.randomQuoteIndex(quotes) and this.randomQuoteIndex(), the call relies on the state of quotes being set first. What am I missing? Thank you for your help! – nCardot Jul 09 '19 at 01:37
  • 1
    If you look at the refactored `this.randomQuoteIndex(quotes)` you'll see it doesn't actually use `this.state` at any point, `quotes` is getting passed in as an argument, and that's why it can be called before `setState` is done updating the component state. – Ryan Jenkins Jul 09 '19 at 01:50
  • I saw it didn't use `this.state`, but my confusion was that for the function to be invoked, it relies on the `quotes` state as an argument. But I see now it's just because everthing within the `componentDidMount` `setState` is applied at once (and not even in order), so `quotes` state is set at the same time as it's needed as an argument in `setState`'s `this.randomQuoteIndex(quotes)`. Anywho, do you know why `this.randomQuoteIndex()` inside `setState` would be called before `setState` (or could you suggest any docs that might explain further)? – nCardot Jul 09 '19 at 05:41
  • 1
    `this.randomQuoteIndex()` is called before `setState` because of [eager evaluation](https://en.wikipedia.org/wiki/Eager_evaluation), that is in javascript arguments are always executed before a function or method is called on them. The output of `this.randomQuoteIndex()` is needed for `setState` to run, so it is evaluated first. – Ryan Jenkins Jul 17 '19 at 03:27
1

Personally I don't really think that React's authors intention about callback in setState was to use it to call next setState. Why not to try something like @Icepickle mentioned:

function randomQuoteIndex(quotes) {
  return random(0, quotes.length - 1);
}
...
...
  .then(quotes => {
    this.setState({
      quotes,
      randomQuoteIndex: randomQuoteIndex(quotes),
      isDoneFetching: true
    })
  }))
...

you update state only once => making sure about you have always just one render cycle

hovnozrout
  • 121
  • 2