2

Let's say I have a piece of logic (e.g. a search bar) and I want to display a spinner during this time. So I have the following code:

this.state = {
    searching: false,
}

invokeSearch() {
    this.setState({ searching: true })

    const fuse = new Fuse(arrayOfData, { keys: [name: 'title'] })
    const searchResults = fuse.search(searchTerm)

    this.setState({ searching: false })
}

I'm storing the searching boolean in state so I can pass it to the component to show or hide the spinner.

I understand that this won't work because setState is asynchronous, thus according to the docs, I can use the callback instead.

The second parameter to setState() is an optional callback function that will be executed once setState is completed and the component is re-rendered. Generally we recommend using componentDidUpdate() for such logic instead.

So my code now looks like this:

this.state = {
    searching: false,
}

invokeSearch() {
    this.setState({ searching: true }, () => {
        const fuse = new Fuse(arrayOfData, { keys: [name: 'title'] })
        const searchResults = fuse.search(searchTerm)

        this.setState({ searching: false })
    })
}

However, this doesn't work as expected. Rendering this.state.searching both as a <Text> component or in some ternary logic to show/hide the spinner does not visibly change from false.

I would guess it's something to do with the asynchronous, batching nature of setState which is causing this not to work?

In order to solve this I have been using the following solution which to me seems like a hack:

this.state = {
    searching: false,
}

invokeSearch() {
    this.setState({ searching: true }

    setTimeout(() => {
        const fuse = new Fuse(arrayOfData, { keys: [name: 'title'] })
        const searchResults = fuse.search(searchTerm)

        this.setState({ searching: false })
    }, 0)
}

Can anyone explain to me why the callback does not work as I expect and how I might solve this in a more elegant manner?

EDIT: REVISED PROBLEM

Thank you for all your insightful answers & comments. After investigating further, I feel that Fuse is not a good example for this issue. I have another example with some asynchronous Redux which I will describe below

When I click on a button, my goal is to show a spinner, update the Redux store (which in turn re-renders a list of items), and then hide the spinner. The Redux is using Thunk so it's asynchronous and I have a callback setup on the action. The reducer isn't doing anything fancy, just updating the Redux store with the new data value.

// action
export function updateFilters(filters, successCallback = () => {}) {
    return (dispatch) => {
        dispatch({ type: ACTIONS.UPDATE_FILTERS, data: filters })
        successCallback()
    }
}
// component
changeFilter = (filters) => {
    this.setState(() => ({
        loading: true,
    }), () => {
        updateFilters(filters, () => {
            this.setState({
                loading: false,
            })
        })
    }
} 

The spinner still doesn't render!

James
  • 330
  • 3
  • 9
  • None of the methods you posted would work, since you are calling an async function. Provide us also that async function code. – kind user Dec 13 '19 at 14:16
  • 2
    could/shouldn't the `searching: false` be set in the callback of the async method (whenever it actually finishes)? – scgough Dec 13 '19 at 14:18
  • By how the code looks like, your searching function should return a `Promise` and then you should update the state once that Promise resolves/rejects: `fuse.search(searchTerm).then(() => this.setState({ searching: false }))` – Andrei Olar Dec 13 '19 at 14:23
  • @kinduser As I said, the last method does work. – James Dec 13 '19 at 14:23
  • @scgough My apologies, I am using Fuse.js which I think is actually synchronous. I have updated my question. – James Dec 13 '19 at 14:24
  • @AndreiOlar I tried it and get this error: `TypeError: fuse.search(...).then is not a function` – James Dec 13 '19 at 14:27
  • Yes, I didn't know Fuse.js is a library you were using, so I assumed you were doing some kind of a network request. – Andrei Olar Dec 13 '19 at 14:29

4 Answers4

2

Asynchronous state update

If you want to be sure to update your state via setState you could pass a function instead of an object as stated in the docs:

To fix it, use a second form of setState() that accepts a function rather than an object. That function will receive the previous state as the first argument, and the props at the time the update is applied as the second argument

State Updates May Be Asynchronous

For instance:

this.setState(prevState => ({ searching: !prevState.searching }));

Or

this.setState(() => ({ searching: true }));

Dealing with your asynchronous function call

You could use async/await async function

One of its benefit is that it pauses the execution of the async function until the promise returned is resolved/rejected then you're sure that you can update your state again which is very convenient

Penny Liu
  • 15,447
  • 5
  • 79
  • 98
t3__rry
  • 2,817
  • 2
  • 23
  • 38
  • Thanks for this. Could you take a look at my revised question? I have tried to pass a function instead of an object but it still refuses to render the spinner. – James Dec 13 '19 at 15:57
0

Make function invokeSearch asynchronous and make use of await. Here is the modified code.

async invokeSearch() {
    this.setState({ searching: true })

    const fuse = new Fuse(arrayOfData, { keys: [name: 'title'] })
    const searchResults = await fuse.search(searchTerm)

    this.setState({ searching: false })
}

It should fix it.

Nilesh Patel
  • 3,193
  • 6
  • 12
0

Have you tried putting the update in the setState callback? Something like:

invokeSearch() {
    this.setState({ searching: true }, () => {
       // do async search
       this.setState({ searching: false })
    }
}
0

Edit: It looks like you're defining the call back inside of your action:

export function updateFilters(filters, successCallback = () => {}) {

instead of just defining the variable that references the callback:

export function updateFilters(filters, successCallback) {

Answer to first question: It still feels like you're setting up a race condition

The first action:

this.setState({ searching: true }

is added to the async stack

(in your 'hackey' solution) You then add the next item into the async stack which is the timeout. It just happens to be that the initial setState resolves before your timeout. In JS there is no guarantee of what will come off the async stack first.

My usual design pattern for this is: setState loading -> in call back perform async call -> inside of that then resolve the async stuff then setState.

In async await syntax which is a bit easier to read:

const invokeSearch = async () =>{

   try{
      //set loading state

      this.setState({ searching: true }
      // if you wanted to make certain you weren't setting up a race condition
      // you could wrap the above in a promise and await the result of that. 
      // that seems a bit overkill and harder to read. 


      // await the results of your search. This will make sure that you don't
      // proceed until you have the results

      const response = await async stuff

      if(response.success){

         // do some stuff with response 
         // then set the new state to searching false as your last step
         this.setState({ searching: false })

      }else{
        //handle errors
      }
   } catch(err){
     console.error(err)
   }
}

As Fetch:

handleSearch(){
     fetch()
    .then(r => r.json)
    .then(data =>{
        //do something with data
        this.setState({searching: false})
     }
}

invokeSearch() {
    this.setState({ searching: true }, handleSearch)
}

Another possible gotcha is that last time I read the docs on setState, it forces a re-render of the page, so I'm not sure if that could be your issue too.

Here's a full react component tested and working if it helps:

import React from "react";

class StarWarsLuke extends React.Component {
  state = {
    searching: false,
    name: ""
  };

  handleSearch = () => {
    fetch("https://swapi.co/api/people/1")
      .then(r => r.json())
      .then(data => {
        console.log(data.name);
        this.setState({ searching: false, name: data.name });
      });
  };

  startSearch = () => {
    this.setState({ searching: true }, this.handleSearch);
  };

  render() {
    return (
      <div>
        <p>
          Am I searching?
          {this.state.searching ? "  searching" : "  not searching"}
        </p>
        {this.state.name ? (
          <p>{this.state.name}</p>
        ) : (
          <p>I don't have a name yet</p>
        )}
        <button onClick={this.startSearch}>get my name</button>
      </div>
    );
  }
}

export default StarWarsLuke;