Jolly
Jolly

Reputation: 1768

Used latest state in Fetch API `then()`

I'm working on a REACT hook component that fetches data using the Fetch API fetch. Though, I'm facing an issue that I'm not quite sure how to solve it, or better said, I'm not sure if is there any "recommended" way to face this issue.

Take this code in example:

const Fetcher = () => {
    [data, setData] = useState([]);

    const handleButtonClick = (e) => {
        fetch('http://www.myapi.com')
            .then(response => response.json())
            .then(json => {
                const newData = [...data];
                newData.push(json);

                setData(newData);
            });
    }

    return <button onClick={handleButtonClick}>Click to fetch</button>
}

It's not a working example, but it's pretty clear what is happening: I click a button and I fetch something; that something is ADDED to the current state.

Now, where is the problem? When I wrote const newData = [...data] I'm considering the data variable that was available in the moment the fetch started, NOT the current data. This means that, if the fetch takes 1 minute to be executed, in that minute the data could have been updated some other way, so, when the fetch.then().then() is called, I override the actual current data with something that is not correct.

I can give you this scheme to make you understand better:

  1. I click and a first fetch is executed with data equals to [ ];
  2. I click again before the first fetch ends (again, with data equals to [ ];
  3. Second fetch ends and in data is saved the new value (e.g. ['foo']);
  4. First fetch ends receiving 'bar'. Since it use its data that is an empty array, it save in data the array ['bar'];

As you can see, at the end of the day, I have an array ['bar'], when instead it should be ['foo', 'bar'].

To this issue, I've come up with two solutions:

  1. Keeping a copy of the state in a ref, and use that in the fetch.then().then(). Something like this:

    const Fetcher = () => {
        [data, setData] = useState([]);
        const refData = useRef(data);
    
        const handleButtonClick = (e) => {
            fetch('http://www.myapi.com')
                .then(response => response.json())
                .then(json => {
                    const newData = [...refData.current];
                    newData.push(json);
    
                    setData(newData);
                });
        }
    
        useEffect(() => {
            refData.current = data;
        }, [data]);
    
        return <button onClick={handleButtonClick}>Click to fetch</button>
    }
    
  2. Use a temporary variable and an useEffect to work on the latest variable:

    const Fetcher = () => {
        [data, setData] = useState([]);
        [lastFetchedData, setLastFetchedData] = useState();
    
        const handleButtonClick = (e) => {
            fetch('http://www.myapi.com')
                .then(response => response.json())
                .then(json => {
                    setLastFetchedData(json);
                });
        }
    
        useEffect(() => {
            const newData = [...data];
            newData.push(lastFetchedData);
    
            setData(newData);
        }, [lastFetchedData]);
    
        return <button onClick={handleButtonClick}>Click to fetch</button>
    }
    

I'm pretty sure that both of them work without creating any major issue, but:

  1. First approach: I don't know, it seems to me it goes against REACT way of thinking. I'm using a ref to maintain the state up-to-date somewhere.. I mean, I don't know if this is OK for REACT "way-of-living";

  2. Second approach: In this case I use only states. What I don't like here is that I do an extra render ANY time some data is fetched. Not big deal, but, you know, if it could be avoided it would be better. I could return an empty page if lastFetchedData is not null, but the user would see that empty page for maybe a millisecond. Actually, it wouldn't see the empty page, but probably the page blink due to the two renders;

Upvotes: 1

Views: 998

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1074355

The usual solution is to use the callback form of setData:

const Fetcher = () => {
    [data, setData] = useState([]);

    const handleButtonClick = (e) => {
        fetch('http://www.myapi.com')
            .then(response => response.json())
            .then(json => {
                setData(data => [...data, json]); // <============
            });
    }

    return <button onClick={handleButtonClick}>Click to fetch</button>
}

Side notes:

  1. Your code is falling prey to the footgun in the fetch API I describe here. You need to check response.ok before calling json.

  2. You aren't handling errors from fetch.

  3. You've forgotten to declare data and setData (I recommend using strict mode so this is an error):

So:

const Fetcher = () => {
    const [data, setData] = useState([]);
//  ^^^^^−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− #3

    const handleButtonClick = (e) => {
        fetch('http://www.myapi.com')
            .then(response => {
                if (!response.ok) {                                   // #1
                    throw new Error("HTTP error " + response.status); // #1
                }                                                     // #1
                return response.json();
            })
            .then(json => {
                setData(data => [...data, json]);
            })
            .catch(error => {                                         // #2
                // Handle/report error here                           // #2
            });                                                       // #2
    }

    return <button onClick={handleButtonClick}>Click to fetch</button>
}

Upvotes: 2

Related Questions