The status quo

Lets start with a simple data-fetching use case. We’ll fetch and render a list of products. The most common approach at the moment is to use fetch inside of a useEffect hook.

const Products = () => {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    const fetchProducts = async () => {
      const responseData = await fetch(getApiURL("products")).then((response) =>
        response.json()
      );

      setProducts(responseData.products);
    };

    fetchProducts();
  }, []);

  return (
    <div>
      <h1>Our Products</h1>
      {products.map((product) => {
        return <Product key={product.id} product={product} />;
      })}
    </div>
  );
};

This works great for small cases in the happy path. However, requests aren’t guaranteed to succeed so we need to take care of error handling.

Lets set up an error state and render a fallback message when loading products fail.

const Products = () => {
  const [products, setProducts] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchProducts = async () => {
      try {
        const responseData = await fetch(getApiURL("products")).then(
          (response) => response.json()
        );

        setProducts(responseData.products);
      } catch {
        setError("Could not load products at the moment");
      }
    };

    fetchProducts();
  }, []);

  return (
    <div>
      <h1>Our Products</h1>
      {error && <ErrorMessage>{error}</ErrorMessage>}

      {products.map((product) => {
        return (
          <ProductLink
            key={product.id}
            image={product.image}
            name={product.name}
            price={product.price}
          />
        );
      })}
    </div>
  );
};

Great, now that that’s taken care of let’s show a loading skeleton while the products are loading.

const Products = () => {
  const [products, setProducts] = useState([]);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchProducts = async () => {
      setIsLoading(true);

      try {
        const responseData = await fetch(getApiURL("products")).then(
          (response) => response.json()
        );

        setProducts(responseData.products);
      } catch {
        setError("Could not load products at the moment");
      }

      setIsLoading(false);
    };

    fetchProducts();
  }, []);

  return (
    <div>
      <h1>Our Products</h1>

      {isLoading ? <ProductsSkeleton /> : null}

      {error && <ErrorMessage>{error}</ErrorMessage>}

      {products.map((product) => {
        return (
          <ProductLink
            key={product.id}
            image={product.image}
            name={product.name}
            price={product.price}
          />
        );
      })}
    </div>
  );
};

Nice! lets take a look at the product details page. We will get the productId from the URL and fetch its details.

const ProductDetails = () => {
  const { productId } = useParams();

  const [productDetails, setProductDetails] = useState(null);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchProductDetails = async () => {
      setIsLoading(true);

      try {
        const responseData = await fetch(
          getApiURL(`products/${productId}`)
        ).then((response) => response.json());

        setProductDetails(responseData.productDetails);
      } catch {
        setError(`Could not load product with id ${productId} at the moment`);
      }

      setIsLoading(false);
    };
  }, [productId]);

  if (isLoading) {
    return <ProductDetailsSkeleton />;
  }

  if (error) {
    return <ErrorMessage>{error}</ErrorMessage>;
  }

  return (
    <div>
      <h1>{productDetails.name}</h1>
      <img src={productDetails.image} alt={productDetails.description} />
      <p>{productDetails.description}</p>
      <p>{productDetails.price}</p>
      // This component makes a network request to another service for fetching product reviews. 
      // To keep it short we won't implement it but it should follow the same 
      // pattern for fetching and handling error and loading states.
      <ProductReviews productId={productDetails.id} />
    </div>
  );
};

As you can see, there is a lot of duplication going on to fetch and handle loading and error states. Every time we need to set up the effect, try/catch block, loading, and error states.

A common approach to solve this duplication is to create reusable hooks which encapsulate the repetitive logic.

const useProducts = () => {
  const { productId } = useParams();

  const [products, setProducts] = useState([]);
  const [productDetails, setProductDetails] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchProducts = useCallback(async () => {
    setIsLoading(true);

    try {
      const responseData = await fetch(getApiURL("products")).then((response) =>
        response.json()
      );

      setProducts(responseData.products);
    } catch {
      setError("Could not load products at the moment");
    }

    setIsLoading(false);
  }, []);

  const fetchProductDetails = useCallback(async () => {
    setIsLoading(true);

    try {
      const responseData = await fetch(getApiURL(`products/${productId}`)).then(
        (response) => response.json()
      );

      setProductDetails(responseData.productDetails);
    } catch {
      setError(`Could not load product with id ${productId} at the moment`);
    }

    setIsLoading(false);
  }, [productId]);

  useEffect(() => {
    if (productId) {
      fetchProductDetails();
    } else {
      fetchProducts();
    }
  }, [productId, fetchProductDetails, fetchProducts]);

  return {
    products,
    productDetails,
    isLoading,
    error,
  };
};

A lot is going on here but in a nutshell, this hook will load either the product details or the products list based on the page being viewed.

const Products = () => {
  const { products, isLoading, error } = useProducts();

  return (
    <div>
      <h1>Our Products</h1>

      {error && <ErrorMessage>{error}</ErrorMessage>}

      {isLoading ? <ProductsSkeleton /> : null}

      {products.map((product) => {
        return (
          <ProductLink
            key={product.id}
            image={product.image}
            name={product.name}
            price={product.price}
          />
        );
      })}
    </div>
  );
};

const ProductDetails = () => {
  const { productDetails, isLoading, error } = useProducts();

  if (!productDetails && (!isLoading || !error)) {
    return null;
  }

  return (
    <div>
      <h1>{productDetails.name}</h1>
      <img src={productDetails.image} alt={productDetails.description} />
      <p>{productDetails.description}</p>
      <p>{productDetails.price}</p>

      <ProductReviews productId={productDetails.id} />
    </div>
  );
};

As you can see, this greatly simplifies our components and removes the need for duplication. However, the hook state is local to each instance, and going back and forth between the product listing and details pages would trigger new requests every time, even though the data hasn’t changed.

This is when usually we reach out for some sort of a global state or store where we can save the data we fetched and access it from different parts of the app. Lets refactor our useProducts hook to work with a store.

const useProducts = () => {
  const { productId } = useParams();

  const setProducts = useStore((store) => store.setProducts);
  const products = useStore((store) => store.products);

  const setProductDetails = useStore((store) => store.productDetails);
  const productDetails = useStore((store) =>
    store.fetchedProductDetails.find((product) => product.id === productId)
  );

  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchProducts = useCallback(async () => {
    if (products.length > 0) {
      return;
    }

    setIsLoading(true);

    try {
      const responseData = await fetch(getApiURL("products")).then((response) =>
        response.json()
      );

      setProducts(responseData.products);
    } catch {
      setError("Could not load products at the moment");
    }

    setIsLoading(false);
  }, [productDetails]);

  const fetchProductDetails = useCallback(async () => {
    if (productDetails) {
      return;
    }

    setIsLoading(true);

    try {
      const responseData = await fetch(getApiURL(`products/${productId}`)).then(
        (response) => response.json()
      );

      setProductDetails(responseData.productDetails);
    } catch {
      setError(`Could not load product with id ${productId} at the moment`);
    }

    setIsLoading(false);
  }, [productId, productDetails]);

  useEffect(() => {
    if (productId) {
      fetchProductDetails();
    } else {
      fetchProducts();
    }
  }, [productId, fetchProductDetails, fetchProducts]);

  return {
    products,
    productDetails,
    isLoading,
    error,
  };
};

Great! Our store is acting as a cache layer and we are only requesting data when we don’t already have it.

A custom hook is not enough

Looking fresh! Not.

This simple cache implementation introduces the problem of stale data. Only the initially fetched products would be displayed and any new products or updates will require a full page refresh.

Abort, abort

Even though we extracted duplicated logic into a reusable hook, we are still using useEffect to trigger requests. React 18 in strict mode renders components twice which means that requests will fire off twice as well.

To avoid this we should set up AbortControl and cancel any ongoing requests in the useEffect cleanup function.

Parent first, children later

When a component is loading data it often shows some sort of a loading indicator or skeleton blocking the rendering of its children.

This creates a waterfall of network requests where the parent data is fetched, children are rendered, and then children data is fetched …etc

In our case, every time we visit the ProductDetails page we’ll wait until the details are fetched and rendered before the ProductReviews can request its data.

In turn, this results in an interface filled with loading indicators.

TL;DR

A lot needs to be taken care of when using a mix of useEffect and a global store:

  1. Data fetching
  2. Request cancellation
  3. Loading state and error state handling
  4. Caching
  5. Stale data and revalidation
  6. Request waterfalls

How, where, and when

To address all these challenges we need to think about data from three different perspectives. How to perform network requests, where to store data, and when to initiate the requests.

How to perform network requests

Fetch is quite a popular and powerful interface for fetching (and mutating) resources. It’s a native API that’s available in all modern browsers.

However, in more complex scenarios it requires a lot of manual work to implement things like error handling and interceptors.

Before fetch came into existence Axios was the go-to library for network requests. It still has about 36 million weekly downloads today. Its development and maintenance slowed down with the rise of Fetch which drove even more people to avoid it. However, maintenance picked up again recently with new versions being released.

Axios is a promise based just like Fetch but built upon XMLHttpRequest instead. It supports better error handling, instances, and interceptors which make things like authenticated requests a lot easier.

Ky is a tiny HTTP client based on the Fetch API. Just like Axios, it supports better error handling and instances. However, it takes a different approach than Axios and implements what they call hooks instead of interceptors.

There are additionally various other HTTP client libraries you could pick from and which one you choose should be based on your project needs. It’s very common to write a small wrapper around Fetch to avoid using third-party libraries.

Where to store data

Fetching data is only one part of the story. In the context of React, data can live in a local component state, a hook state, a react context, a global store, or in a cache.

To pick the best place we need to differentiate between server and client data.

Server data (or async data) is everything that originates from an external server. This is typically anything we fetch over the network.

Client data on the other hand (often referred to as client state) is anything that originates from the client. This is mainly the UI state such as components’ open/closed state, form fields values, and themes.

Client data can be managed in different ways as well and we’ll explore the different patterns in another post. In this one, we’ll focus on server data.

Referring back to the status quo, we saved our server data in a custom hook first and then in a global store which solved some problems but not all.

To recap, we need:

  1. Cache layer that also takes care of revalidation.
  2. Loading and error state management
  3. Automatic request cancellation

TanStack Query (ReactQuery)

Similar to how the Apollo client caches GraphQl queries and eliminates the need for a global state. Query caches the results of any async operations and controls its revalidation and sync with the server.

It also enables things like optimistic updates and automatic error and loading state handling.

Lets refactor our useProducts hook and see the benefits of a proper client cache layer

export const useProducts = () => {
  const { isLoading, error, data } = useQuery({
    queryKey: ["products"],
    queryFn: () =>
      fetch(getApiURL("products")).then((response) => response.json()),
  });

  return {
    products: data,
    isLoading,
    error,
  };
};

export const useProduct = (producId) => {
  const { isLoading, error, data } = useQuery({
    queryKey: ["products", producId],
    queryFn: () =>
      fetch(getApiURL(`products/${productId}`)).then((response) =>
        response.json()
      ),
  });

  return {
    productDetails: data,
    isLoading,
    error,
  };
};

First of all, we split our hook into two to keep the loading and error states separate. We then got rid of the store, the useEffect, the local state, and manual error handling.

When used, each hook will trigger the network request and store its result in the cache with the queryKey for later reference.

It’ll automatically cancel duplicated requests and return the cached value while re-fetching the data and re-validating the cache in the background.

Navigating between the product listing and details pages now feels instant after the first load.

Another great hidden feature is refreshing the cache on window focus.

By default Query is aggressively revalidating the cache. However, it’s configurable and we can provide a duration for which no revalidation is required, saving network bandwidth and reducing server load when needed.

Query have so much more to provide and I encourage you to visit its website to learn more about it.

You may also check the video below, it’s a little outdated and the syntax has changed a bit since but the core concepts still stand.

All about React Query

When to initiate requests

So far we managed to overcome almost all our challenges regarding fetching and storing and managing data. The only thing remaining from our list is parents blocking children and the loading indicators.

Even though Query helps a little with caching, children still need to wait for parents to render before initiating their requests.

Requests can be initiated at different points during the application life cycle. In SSR, data is fetched on the server before even the view is constructed, in CSR requests are commonly initiated in a useEffect after rendering causing requests waterfall.

If we could somehow initiate requests before rendering, we could load data for both the product details and its reviews at the same time, eliminating the need for nested loading indicators.

The secret is in the URL

In almost all cases, the URL determines what data is needed for the layout. In our example, we load the product listing when on /products and the single product details when on /products/:productId.

Since we already know what data is needed for each route we can initiate the request before the page is rendered.

This can be achieved through ReactRouter v6.4 route loaders.

import {
  createBrowserRouter,
  createRoutesFromElements,
} from "react-router-dom";

// App.ts
createBrowserRouter(
  createRoutesFromElements(
    <Route
      path="products"
      loader={() => {
        // Loaders understand the Fetch API and will automatically
        // unwrap response.json() when directly returning `fetch`
        return fetch(getApiURL("products"));
      }}
      element={<Products />}
      errorElement={<ErrorBoundary />}
    >
      <Route
        path=":productId"
        loader={async ({ params }) => {
          const productDetails = await fetch(
            getApiURL(`products/${params.productId}`)
          ).then((response) => response.json());

          const productReviews = await fetch(
            getApiURL(`reviews/${params.productId}`)
          ).then((response) => response.json());

          return {
            productDetails,
            productReviews,
          };
        }}
        element={<ProductDetails />}
      />
    </Route>
  )
);

We use the createBrowserRouter and createRoutesFromElements functions to register our routes in JSX. ReactRouter v6 introduced some API changes. Check the docs for more information on how to use ReactRouter V6.

For each route, we set up a loader function that should return the data that the component needs. React Router will request and load the data before the component is rendered.

In the products/:productId route, we fetch both the product details as well as its reviews at the same time.

In the case of errors, the errorElement component would render instead of element, and errors in nested routes with no errorElement would bubble to the nearest parent with an errorElement.

Loaded data is accessible through the useLoderData() hook.

import { useLoaderData } from "react-router-dom";

const Products = () => {
  const products = useLoaderData();

  return (
    <div>
      <h1>Our Products</h1>

      {products.map((product) => {
        return (
          <ProductLink
            key={product.id}
            image={product.image}
            name={product.name}
            price={product.price}
          />
        );
      })}
    </div>
  );
};

const ProductDetails = () => {
  const { productDetails, productReviews } = useLoaderData();

  return (
    <div>
      <h1>{productDetails.name}</h1>
      <img src={productDetails.image} alt={productDetails.description} />
      <p>{productDetails.description}</p>
      <p>{productDetails.price}</p>

      <ProductReviews reviews={productReviews} />
    </div>
  );
};

No loading state, no conditional rendering, and no nested loading indicator.

Of course, we may choose to instantly load the components with some sort of a loading indicator if the requested data takes some time to load, otherwise, our application might appear frozen.

React Router provides a way to do so through the defer utility along with React Suspense.

Feel free to read more about this and all the other features of ReactRouter on their website.

What about Query?

We just spent almost all of this article moving to Query for data management. How does it integrate with route loaders?

We can quickly refer back to the how, where, and when of data management. ReactRouter is about the “when” and it doesn’t store any data nor provide caching. Every time we visit a route, its data will be requested again.

It’s time to bring Query back again to the mix to cache the results of route loaders.

Next to the useProducts hook we define and export the route loader. It takes the queryClient as a dependency and returns either cached data or fetches fresh one and as we said above when accessing cached data Query will refresh in the background anyways.

// useProducts.ts
const query = {
  queryKey: ["products"],
  queryFn: () =>
    fetch(getApiURL("products")).then((response) => response.json()),
};

// Loaders can be defined anywhere and exported
// for usage with route registration
export const productsLoader = (queryClient) => async () => {
  return (
    // return cached data or fetch it if not available
    queryClient.getQueryData(query.queryKey) ??
    (await queryClient.fetchQuery(query))
  );
};

export const useProducts = () => {
  const { isLoading, error, data } = useQuery(query);

  return {
    products: data,
    isLoading,
    error,
  };
};

In the route registration, we use the exported productsLoader and supply it with queryClient.

// App.ts
import {
  createBrowserRouter,
  createRoutesFromElements,
} from "react-router-dom";
import { QueryClient } from "@tanstack/react-query";

import { productsLoader } from "./useProducts";

const queryClient = new QueryClient();

createBrowserRouter(
  createRoutesFromElements(
    <Route
      path="products"
      loader={productsLoader(queryClient)}
      element={<Products />}
      errorElement={<ErrorBoundary />}
    >
      ...
    </Route>
  )
);

In our component, we use the useProducts hook as usual.

// Products.ts
const Products = () => {
  const { products } = useProducts();

  return (
    <div>
      <h1>Our Products</h1>

      {products.map((product) => {
        return (
          <ProductLink
            key={product.id}
            image={product.image}
            name={product.name}
            price={product.price}
          />
        );
      })}
    </div>
  );
};

This might be a lot to digest so let us go through what will happen step by step:

  1. User visits the /products route
  2. Before rendering ReactRouter invokes the productsLoader
  3. productLoader will look inside the cache to see if we have any products, it won’t find any.
  4. productLoader initiates a request to fetch the products, return the data, and cache it.
  5. ReactRouter will render the <Products> component
  6. <Products> will use useProducts which in turn uses useQuery to look inside the cache.
  7. useProducts finds the cached data and return it

In other words, we populate the cache when the route is visited before rendering the component. By the time the component renders the data would already be cached and accessible.

Summary

By utilizing Query and ReactRouter we can overcome most of the challenges that come with server (async) data.

Fetching and loading resources as early as possible increases performance while caching eliminates loading indicators and increases perceived performance and speed.

Together they also greatly improve the development experience by reducing the complexity and amount of the needed code.

For more details about mutations, error handling, web sockets, and more checkout this blog series by one of Query maintainers, TkDodo.

Want to know more about how we can work together and launch a successful digital energy service?