Managing data retrieval in React

Managing data retrieval in React

Mehdi Abaakouk

Consuming API in React sounds straightforward. Fetching data can be resumed by using the `fetch` function and getting back the value. Using this simple approach would be fine if everything always worked as intended.

However, fetching data is way more than retrieving some value. You have to deal with unexpected behaviors: the network can be down or flaky, the server might replies with an HTTP 500 error, etc. For a React application, you also need to know when the request is running, when it succeeds, or when it fails to display appropriate messages to your users. Requesting the same data repeatedly when a component is created or updated would be ineffective; therefore, you need a caching layer.

Unfortunately, React does not help to deal with any of that.

Solving this yourself

To solve all those issues at once, you first need to use states. They are used to store the HTTP request status (loading, success, or error) and then store the error content or request result.

Then you need an AbortController to cancel HTTP requests. It would be best if you also had a global object to cache the HTTP requests' response in order to not query it again and again. Finally, it would help if you made sure useEffect() to trigger the fetch only once per render.

The typical code snippet to achieve all of this correctly looks like the following.

// Worse cache management ever, no expiration and invalidation method...
const userCache = new Map()

function UserName(props) {
    const {userId} = props;
    const [user, setUser] = useState(null);
    const [error, setError] = useState(null);
    const [isError, setIsError] = useState(false);
    const [isLoading, setIsLoading] = useState(false);
    const [isSuccess, setIsSuccess] = useState(false);

    useEffect(() => {
        setUser(null);
        setError(null);
        setIsLoading(true);
        setIsSuccess(false);
        setIsError(false);

        const controller = AbortController();
        const fetcher = async () => {
            // Check the catch first
            if (userCache.has(userId) {
               setUser(userCache.get(userId));
               setError(null);
               setIsLoading(false);
               setIsSuccess(true);
               setIsError(false);
            } else {
                try {
                    const response = await fetch(`/api/user/${userId}`, {signal: controller.signal});
                    userCache.set(response.json());
                    setUser(response.json());
                    setError(null);
                    setIsLoading(false);
                    setIsSuccess(true);
                    setIsError(false);
                } catch (e) {
                    if (e.name === 'AbortError') {
                        // The request have been cancel, nobody is expected the result of this closure
                        return;
                    }
                    setUser(null);
                    setError(e);
                    setIsLoading(false);
                    setIsSuccess(false);
                    setIsError(true);
                }
        });

        // run our closure
        fetcher();

        // Ensure useEffect will cancel our http request and not trigger unexpected state change
        return () => {controller.abort();}
    }, [userId]);
    return (
        <>
            {isLoading && (<i>Loading...</i>)}
            {isError && <Alert>An error occurs: {error}</Alert>'}
            {isSuccess && <b>{user.name}</b>'}
        </>
    )
}
export default function App() {
    return <UserName userId={123}>
}

This solution is quite inelegant.

First, it requires to maintain many states β€” and even with those, the cache management is terrible. Then, the code does not retry on network issues. Also keep in mind that this code is just for one HTTP request.

Any piece of code following this pattern is far from being production-ready. If you were to use this snippet, you would have to copy and adapt it for every API call that you would do. An exhausting, never ending work.

Therefore, the next logical step would be to create some custom React hooks to hide this complexity and avoid repeating oneself. Again, a considerable project.

Leverage existing solutions

As you can imagine, it turns out many people already went through his issue and consequently wrote battle-tested libraries as a solution.

For example, Mobx and Redux are a try at this. They focus on the state management of API calls, but they don't take care of the whole request lifecycle: you need to write many reducers or observers to use them. They also don't handle retry natively.

Both those libraries are good at what they do, but they are not that simple to use.

On the other hand, the SWR and react-query libraries both implement everything that is needed, from the request lifecycle to the state management.

We can actually rewrite our example above using react-query:


import { QueryClient, QueryClientProvider, useQuery } from "react-query";

function UserName(props) {
    const {userId} = props;

    const user = useQuery(["user", userId], () => {
      const controller = new AbortController();
      const promise = fetch(`/api/user/${userId}`, {signal: controller.signal}).then((reponse) => response.json());
      // Magic method to tell react-query how to cancel our http request
      promise.cancel = () => controller.abort();
      return promise;
    },

    return (
        <>
            {user.isLoading && (<i>Loading...</i>)}
            {user.isError && <Alert>An error occurs: {user.error}</Alert>'}
            {user.isSuccess && <b>{user.data.name}</b>'}
        </>
    )
}

// Our global react-query configuration
const queryClient = new QueryClient(
  defaultOptions: {
    queries: {
      // retry Network Error 3 times.
      retry: (failureCount, error) => (
       failureCount <= 3 && (error.name === 'NetworkError')
      ),
      // Ignore AbortError
      onError: (error) => { (error.name !== 'AbortError') && throw error) };
    }
  }
);

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
       <UserName userId={123}>
    </QueryClientProvider>
  );
}

React-query is an excellent choice. This library is maintained by the community and handles the state and cache management, the retry logic, and the request lifecycle in only a couple of lines. It doesn't make any assumptions on how you retrieve your data: you can use fetch, axios, octokit, or whatever you need.

For our use-case above, there's no need to do any manual tracking of the state. With just one hook, we're able to implement our usage pattern.

While SWR has a very similar API, it lags behind react-query in terms of features and doesn't yet handle request cancellation. That's the main reason we would prefer react-query.

Beyond querying

React-query has even more features to offer. For example, you can define placeholding data to display until the request is complete. You can automatically get data refreshed based on page focus, network re-connection, or time intervals.

It also supports pagination or infinite query.

React-query is a robust toolkit for managing your data retrieval and lifecycle. You really should leverage it rather than trying to solve all those issues yourself!