wolf_math
wolf_math

Reputation: 223

React loading unknown number of API calls with hooks

The project I'm working on requires a lot of API calls to the backend server using useApiGet. Some components make several API calls (4 or 5), while others only make 1. I want to create a custom hook that can take an array of objects {name, endpoint} and return the API data. This is mostly intended to help with error handling.

useDataLoader.js

const useDataLoader = (arrayOfEndpoints) => {
  let dataArray = []
  arrayOfEndpoints.forEach(endpoint => {
    const [data, error] = useApiGet(endpoint.path)
    if (error.res.status === 404 return <NotFound />
    dataArray.push({name: endpoint.name, data})
  }

  return dataArray
}

MyComponent.js

import useDataLoader from ...

export default function MyComponent () {
  const [dataA, dataB, dataC, dataD] = useDataLoader([
    {name: dataA, path: endpointA}, 
    {name: dataB, path: endpointB}, 
    {name: dataC, path: endpointC}, 
    {name: dataD, path: endpointD}, 
  ])

  ...other stuff

  return (...)
}

useDataLoader should return an array of objects. The problem however is that useApiGet is also a hook and cannot be used inside a loop. What is an alternative way to do this?

The only other way I can think of this would be to return a <NotFound /> after every single API call which would create an issue of lots of duplicate code and code complexity (<NotFound /> could not be right after each API call because other hooks would be called conditionally). That would look like this:

export default function MyComponent () {
  const [data: dataA, error: errorA] = useApiGet(endpointA)
  const [data: dataB, error: errorB] = useApiGet(endpointB)
  const [data: dataC, error: errorC] = useApiGet(endpointC)
  const [data: dataD, error: errorD] = useApiGet(endpointD)

  ...other stuff

  const errors = [errorA, errorB, errorC, errorD]
  errors.forEach(error => {if (error.res.status === 404 return <NotFound />})

  return (...)
}

This would need to be done for each component and would not be ideal.

My question is NOT "Why can't I use a React Hook in a loop?", which has been answered many times. This is understood. The question is "how can I make an indefinite number of API calls using this hook instead of writing a LOT of duplicate code?"

Upvotes: 0

Views: 217

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1074248

Preface

To my mind, you're best off sticking with individual calls to useGetApi. That way, not only are you not violating the rules as described on the page you linked in the comments (although see below, I think those are slightly over-stated for simplicity), but the code is very declarative and straightfoward. If you had useGetApi return the usual [loading, data, error] tuple, the code is;

const [aLoading, a, aError] = useGetApi("a");
const [bLoading, b, aError] = useGetApi("b");
const [cLoading, c, cerror] = useGetApi("c");
// ...
if (aError || bError || cError) {
    return <NotFound />;
}

then rendering a (for instance) is:

{aLoading ? <Loading /> : a}

But, the premise of your question is that you don't want to do that. And certainly it would be very easy to add a d to the above but forget to update the if (aError || bError || cError) condition. So the answer below suggests how you might do your data loader concept.

Answer

As you say, the documentation says not to run hooks in loops (including in the beta documentation). But this is an oversimplification to help people avoid getting themselves in trouble. The real issue is stated later in the page of that first link:

So how does React know which state corresponds to which useState call? The answer is that React relies on the order in which Hooks are called.

(their emphasis) followed by:

As long as the order of the Hook calls is the same between renders, React can associate some local state with each of them. But what happens if we put a Hook call (for example, the persistForm effect) inside a condition?

As you can see, the real issue is that the same hooks must be called in the same order on every render. As long as your loop is unchanging — it calls the same hooks in the same order every time — there's no problem with using a loop. In fact, it's impossible for React to tell the difference between:

const [a, setA] = useState(0);
const [b, setB] = useState(0);
const [c, setC] = useState(0);
const [d, setD] = useState(0);

and

// Don't do this, it's just for illustration
const stuff = [];
for (let n = 0; n < 4; ++n) {
    stuff.push(useState(0));
}

Either way, useState is called four times. (That said, I've asked the React documentation team for input on this.)

With that in mind:

You haven't shown useGetApi, but the usual thing is for it to return a tuple (fixed-length array, like useState does) with three elements: a loading flag, the data (if it has it), or an error (if it has one). That is: [loading, data, error]. If loading is false and error is null, that means data is valid.

Assuming a structure like that for useGetApi, you could have useDataLoader load multiple items and aggregate loading flags and error states for you.

There are lots of ways to spin this.

One thing you could do is have useDataLoader return a count of the number of items that are loading, a count of the number of items with errors, and then an element for each call to useGetApi containing the array it returned. So instead of:

const [aLoading, a, aError] = useGetApi("a");
const [bLoading, b, bError] = useGetApi("b");
const [cLoading, c, cError] = useGetApi("c");
// ...
if (aError || bError || cError) {
    return <NotFound />;
}
// ...

you could do:

const [
    loadingCount,
    errorCount,
    [, a],   // Or [aLoading, a, aError]
    [, b],   // Or [bLoading, b, bError]
    [, c],   // Or [cLoading, c, cError]
] = useDataLoader("a", "b", "c");
// ...
if (errorCount > 0) {
    return <NotFound />;
}
if (loadingCount > 0) {
    return <Loading />;
}
// ...use `a`, `b`, and `c` here...

That gives your component the full picture, both aggregate and detail, so that if you wanted to handle the loading aspect of a, b, and c discretely, you could:

const [
    loadingCount,
    errorCount,
    [aLoading, a],
    [bLoading, b],
    [cLoading, c],
] = useDataLoader("a", "b", "c");
// ...
if (errorCount > 0) {
    return <NotFound />;
}

then rendering (say) a, we have the same thing we had with individual calls:

{aLoading ? <Loading /> : a}

Or if you know you never care about the individual loading flags or error messages, you could return just the counts and the data instead. For some reason, in my head, that would argue for a fixed number of elements: [loadingCount, errorCount, dataArray]:

const [
    loadingCount,
    errorCount,
    [ a, b, c],
] = useDataLoader("a", "b", "c");
// ...
if (errorCount > 0) {
    return <NotFound />;
}
// ...
if (loadingCount > 0) {
    return <Loading />;
}
// ...use `a`, `b`, and `c` here...

Here's a rough sketch (and only a rough sketch) of how useDataLoader might look if it provided the full details:

const useDataLoader = (...endpoints) => {
    let loadingCount = 0;
    let errorCount = 0;
    const dataArray = endpoints.map((endpoint) => {
        const apiResult = useGetApi(endpoint);
        const [loading, , error] = apiResult;
        if (loading) {
            ++loadingCount;
        }
        if (error) {
            ++errorCount;
        }
        return apiResult;
    });
    // Return the number of loading items, number of error items, and then
    // the `[loading, data, error]` arrays for each item in the arguments
    return [loadingCount, errorCount, ...dataArray];
};

Here's a live example. Again there are a dozen different was to spin this and I would probably never do it quite this way in reality, but you wanted a single "error" state, so... This loads three items via API calls, where each API call has a 1:10 chance of failing. It shows the items as they're loading, but switches to a single erorr state if any of them fail.

const { useState, useEffect } = React;

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const useGetApi = (endpoint) => {
    const [loading, setLoading] = useState(true);
    const [data, setData] = useState(null);
    const [error, setError] = useState(null);

    useEffect(() => {
        setLoading(true);
        // Add an artificial, varying delay
        delay(Math.floor(Math.random() * 3000)).then(() =>
            fetch(`https://jsonplaceholder.typicode.com${endpoint}`)
                .then((res) => {
                    if (!res.ok) {
                        throw new Error(`HTTP error ${res.status}`);
                    }
                    // Make one in 10 calls fail, to test the error path
                    if (Math.random() < 0.1) {
                        throw new Error();
                    }
                    return res.json();
                })
                .then(setData)
                .catch(setError)
                .finally(() => setLoading(false))
        );
    }, []);

    // Fairly common pattern: loading flag, data, error
    return [loading, data, error];
};

const useDataLoader = (...endpoints) => {
    let loadingCount = 0;
    let errorCount = 0;
    const dataArray = endpoints.map((endpoint) => {
        const apiResult = useGetApi(endpoint);
        const [loading, , error] = apiResult;
        if (loading) {
            ++loadingCount;
        }
        if (error) {
            ++errorCount;
        }
        return apiResult;
    });
    // Return the number of loading items, number of error items, and then
    // the `[loading, data, error]` arrays for each item in the arguments
    return [loadingCount, errorCount, ...dataArray];
};

const Loading = () => <em>Loading...</em>;

const Example = () => {
    // prettier-ignore
    const [
        loadingCount,
        errorCount,
        [aLoading, a],
        [bLoading, b],
        [cLoading, c],
    ] = useDataLoader("/posts/1", "/posts/2", "/posts/3");
    const [ticker, setTicker] = useState(0);

    // This is just here to show re-rendering unrelated to the loading of
    // posts works just fine
    useEffect(() => {
        const timer = setInterval(() => {
            setTicker((t) => t + 1);
        }, 1000);
        return () => {
            clearInterval(timer);
        };
    }, []);

    if (errorCount > 0) {
        return <div>Errors loading {errorCount} item(s)...</div>;
    }
    return (
        <div>
            <div>
                <strong>Ticker</strong>: {ticker} (just to show state unrelated to the data loading)
            </div>
            <div>
                <strong>A</strong>: {aLoading ? <Loading /> : a.title}
            </div>
            <div>
                <strong>B</strong>: {bLoading ? <Loading /> : b.title}
            </div>
            <div>
                <strong>C</strong>: {cLoading ? <Loading /> : c.title}
            </div>
        </div>
    );
};

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Example />);
<div>Run several times, there's a 1:10 chance of an artificial API failure, and 3 API calls per run, so roughly one in three times you run it, you'll see an error.</div>
<hr>
<div id="root"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>

Upvotes: 1

Related Questions