4

I have a react component (let's call it Logs) which includes another react component (let's call it DateChanger) which, let's say for the sake of this example, only serves to change the date of the parent component. And when Logs gets the date change, it makes an async call to update it's own state with data from a server:

class Logs extends React.Component {
    ....

    onDateChange(newDate) {
        this.setState({loading: true, date: newDate});
        asyncCall(newDate)
            .then(results => {
                this.setState({data: results, loading: false})
            });
    }

    render() {
        return (
            ....
            <DateChanger onChange={this.onDateChange}>
            ....
        )
    }
}

The problem I'm having is that, if someone changes the date twice in quick succession, the rendered 'data' is not always from the correct date.

So what I mean specifically is, in this example, DateChanger has a button to change the date by 1 day, forward and backward. So today is the 5th, and someone can click the back button on the date changer to request data from the 4th, and then click it again to request data from the 3rd.

Some of the time, the asyncCall returns the results in the wrong order -- you click back once and request the 4th, then click it again and request the 3rd before the 4th is returned and occassionally the server returns the data for the 3rd, and then the data for the 4th, and it renders to the user the data for the 4th because that was the most recent .then processed.

What is the React way to make sure that it renders the data from the 3rd instead of the 4th, regardless of which server call returns first?

[edit] this is not about using the setState callback. If I moved my asyncCall inside the setState callback, this problem would still remain. (Although if you want to suggest that I should be moving my asyncCall inside the setState callback, I will gladly take that advice -- it just doesn't solve my problem)

TKoL
  • 13,158
  • 3
  • 39
  • 73
  • You're totally right, I misread the question, sorry! Retracted my close vote. Interesting issue though, I'm not totally sure how you'd solve that. – Joe Clay Mar 05 '18 at 10:48
  • Is asyncCall done using `fetch`? Can you add that part as well? – sabithpocker Mar 05 '18 at 10:54
  • Not posting as an answer because I have no idea if it'd work/is idiomatic, but could you potentially [wrap your `Promise` to make it cancellable (see the bottom of this blog post)](https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html), and then have `onDateChange` cancel any outstanding async calls? – Joe Clay Mar 05 '18 at 10:57
  • @sabithpocker not in this particular example, but surely that shouldn't matter – TKoL Mar 05 '18 at 11:06
  • 1
    @JoeClay that's one thing I've thought about. I also asked this question on a programming discord and was told to just give each request inside of `onDateChange` a request id, and make sure the request id is the same in the `.then` callback, which seems a lot simpler – TKoL Mar 05 '18 at 11:07
  • I can think of [throttling/debouncing](https://stackoverflow.com/questions/23123138/perform-debounce-in-react-js) events at the input side or [cancelling in-flight requests](https://stackoverflow.com/questions/31061838/how-do-i-cancel-an-http-fetch-request) at the other end. A combination of both can also be used. I don't work with React much to know how its done in React as opposed to `Rx` – sabithpocker Mar 05 '18 at 11:17
  • 1
    @sabithpocker that AbortController concept is pretty interesting, thanks for that. I won't be using that now but I might in the future, it's something I've had to think about and been close to manually implementing myself. – TKoL Mar 05 '18 at 11:46

1 Answers1

2

For a quick and dirty solution, you could check if the response you get is the one your component is looking for.

onDateChange(newDate) {
    this.setState({loading: true, date: newDate});
    asyncCall(newDate)
        .then(results => {
            // Update state only if we are still on the date this request is for.
            if (this.state.date === newDate) {
                this.setState({data: results, loading: false})
            }
        });
}

But a solution with better separation of concerns and higher reusability could look like this.

// Request manager constructor.
// Enables you to have many different contexts for concurrent requests.
const createRequestManager = () => {
    let currentRequest;

    // Request manager.
    // Makes sure only last created concurrent request succeeds.
    return promise => {
        if (currentRequest) {
            currentRequest.abort();
        }

        let aborted = false;

        const customPromise = promise.then(result => {
            if (aborted) {
                throw new Error('Promise aborted.');
            }

            currentRequest = null;

            return result;
        });

        customPromise.abort = () => {
            aborted = true;
        };

        return customPromise;
    };
};

class Logs extends React.Component {
    constructor(props) {
        super(props);

        // Create request manager and name it “attemptRequest”.
        this.attemptRequest = createRequestManager();
    }

    onDateChange(newDate) {
        this.setState({loading: true, date: newDate});
        // Wrap our promise using request manager.
        this.attemptRequest(asyncCall(newDate))
            .then(results => {
                this.setState({data: results, loading: false})
            });
    }

    render() {
        return (
            ....
            <DateChanger onChange={this.onDateChange}>
            ....
        )
    }
}

Have in mind that we are not actually aborting the request, only ignoring its result.

jokka
  • 1,832
  • 14
  • 12
  • 1
    Yeah I think most of the time your simpler option will be the one I need, that's the one I've implemented already (store a date-stamp before the requst and then checking that the stored date-stamp is the same after the request) – TKoL Mar 05 '18 at 16:02