Implement fetching & caching mechanism in React

Sun Oct 01 2023

To fetch data from an API and display a response in a React application, you need to create a component that handles the API request. Inside the component, you can use the useState hook to initialize a state variable to hold the fetched data. Then, use the useEffect hook to fetch the data when the component mounts. Inside the useEffect hook, make an HTTP request to the API using methods like fetch or a library like Axios. Once you receive the response, update the state variable with the fetched data. Finally, render the response in the component's JSX, accessing the relevant data from the state.

Yup, that is great but so much code, and not efficient, The main thing is you can't cache your API responses, So if your server is a bit slow and you don’t want to keep requesting the same query then caching at the client side will be the option for you. Caching API responses can improve the performance and efficiency of your application by reducing unnecessary network requests.

There are plenty of libraries that give you this out of the box, such as TanStackQuery and a few more.

Understanding how things work instead of relying solely on libraries can have several advantages:

  • Flexibility and customization: When you have a deep understanding of how things work, you have more control and flexibility over your code. You can customize and adapt solutions to meet specific requirements without being limited by the functionalities provided by a library.
  • Troubleshooting and debugging: When you encounter issues or bugs in your code, having a deeper understanding of the underlying mechanisms can help you troubleshoot and debug more effectively. You can identify and fix problems by examining the code and the logic behind it.
  • Efficiency and performance: Libraries often have additional overhead, such as extra dependencies, size, or processing time. By understanding how things work, you can optimize your code for efficiency and performance, avoiding unnecessary dependencies or streamlining processes.
  • Learning and growth: Exploring how things work on a fundamental level allows you to expand your knowledge and skills. It enhances your ability to grasp new concepts, solve complex problems, and adapt to changing technologies and frameworks.

However, it’s important to strike a balance. Libraries and frameworks can provide convenience, speed up development, and handle complex tasks more efficiently. They are often built by experts and undergo rigorous testing. Leveraging libraries can save time and effort, especially for common or well-established functionalities.

Ultimately, the choice between understanding how things work and relying on libraries depends on the specific context, project requirements, and personal preferences. A combination of both approaches can often yield the best results, leveraging libraries when appropriate and delving into the underlying concepts when necessary.

To understand what is caching let's understand first what is cache. In computing, a cache is a temporary storage location that stores frequently accessed data or instructions to expedite future requests for that data. The purpose of a cache is to improve system performance by reducing the time and resources required to retrieve data from the source.

Okay now we understand that cache means temporary data storage, so how do we store this cache, but more importantly when we store some data how will we access the particular data you need efficiently? So you can store a cache with a key. So the best way is a creating an object with keys and data as values, or a map data structure to perform the same.

So here starts the implementation of cache in react (not fetching just caching).

This cache should be available to all components, so let's keep this context and wrap over the main component.

contexts/Cache.tsx
import { createContext, useContext, ReactNode } from "react";

type ContextType = {
  getCache: (key: string) => any;
  setCache: (key: string, value: any, ttl?: number) => void;
  clearCache: () => void;
  deleteCache: (key: string) => void;
};

type cacheBody = {
  expiry: Date;
  data: any;
};

const CacheContext = createContext<ContextType | null>(null);

export function useCache() {
  return useContext(CacheContext) as ContextType;
}

export default function CacheProvider({ children }: { children: ReactNode }) {
  const map = new Map<string, cacheBody>();

  function getCache(key: string) {
    const cacheValue = map.get(key);
    if (!cacheValue) return undefined;
    if (new Date().getTime() > cacheValue.expiry.getTime()) {
      map.delete(key);
      return undefined;
    }
    return cacheValue.data;
  }

  function setCache(key: string, value: any, ttl: number = 10) {
    var t = new Date();
    t.setSeconds(t.getSeconds() + ttl);
    map.set(key, {
      expiry: t,
      data: value
    });
  }

  function clearCache() {
    map.clear();
  }

  function deleteCache(key: string) {
    map.delete(key);
  }

  const contextValue = {
    getCache,
    setCache,
    clearCache,
    deleteCache
  };

  return (
    <CacheContext.Provider value={contextValue}>
      {children}
    </CacheContext.Provider>
  );
}

As now the context is created let's wrap our App with this <CacheContext>. We can see the cache context gives you four methods to set, delete, get & empty the cache, The set method takes a key string, a value of type any, and a TTL (Time to live in seconds) which determines when this cache should be invalidated. The get method gives you a cache if present under expiry time otherwise gives you undefined, The other two functions are to remove a cache or to empty a cache respectively.

<CacheProvider>
  <App />
</CacheProvider>

So, now our caching mechanism is ready, but as we aim to cache the API responses, But now if we check if the cache is present in every component while making a request would not be a great idea. Instead, we can create a custom hook that will do everything for us out of the box, We just need to provide the API URL, key, and a few configs.

useFetch.tsx
import { useEffect, useState } from "react";
import { useCache } from "../contexts/Cache";
import axios, { AxiosRequestConfig, AxiosError } from "axios";

function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
type CacheEnable =
  | {
      enabled: true;
      ttl?: number;
      suspense?: number;
    }
  | {
      enabled: false;
    };

type CustomAxiosConfig<T = any> = AxiosRequestConfig & {
  key: Array<unknown>;
  initialEnabled?: boolean;
  cache?: CacheEnable;
  onSuccess?: (data: T) => void;
  onFailure?: (err: AxiosError) => void;
};

function keyify(key: CustomAxiosConfig["key"]) {
  return key.map((item) => JSON.stringify(item)).join("-");
}

export default function useFetch<T = any>({
  key,
  initialEnabled = true,
  cache,
  ...axiosConfig
}: CustomAxiosConfig<T>) {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<T | undefined>();
  const [error, setError] = useState<any>();
  const { getCache, setCache, deleteCache } = useCache();

  const refetch = (hard: boolean = false) => {
    setLoading(true);
    setError(undefined);
    const cacheKey = keyify(key);
    if (cache?.enabled && getCache(cacheKey) !== undefined && !hard) {
      const val = getCache(cacheKey);
      delay(cache.suspense || 400).then(() => {
        setData(val);
        setLoading(false);
        setError(undefined);
      });
    } else {
      axios(axiosConfig)
        .then(({ data }) => {
          setData(data as T);
          if (cache?.enabled) setCache(cacheKey, data, cache.ttl ?? 20);
        })
        .catch((err: AxiosError) => {
          setError(err);
        })
        .finally(() => {
          setLoading(false);
        });
    }
  };

  function inValidate(invalidationKey: CustomAxiosConfig["key"]) {
    deleteCache(keyify(invalidationKey));
  }

  useEffect(() => {
    if (initialEnabled) refetch();
    // eslint-disable-next-line
  }, []);

  return { loading, data, error, refetch, inValidate } as const;
}

Here is a custom hook called useFetch which can be called a component and it provides you with all the benefits of state management. You don’t have to maintain multiple states such as loading, error, and data in components. This is all returned by the hook itself. The hook also provides you methods to refetch and invalidate a query.

App.tsx
import useFetch from "./hooks/useFetch";
import type { User } from "./User.type";
import "./styles.css";

type APIUserResponse = {
  results: User[];
};

export default function App() {
  const { loading, error, data, refetch } = useFetch<APIUserResponse>({
    url: "https://randomuser.me/api",
    method: "get",
    key: ["app", "get", "user", { name: "nisab" }],
    cache: {
      enabled: true,
      ttl: 10,
      suspense: 400
    }
  });

  if (loading) {
    return <p>Loading...</p>;
  }
  if (error) {
    return <p>Something went wrong</p>;
  }

  return (
    <div className="App">
      {JSON.stringify(data?.results[0])}
      <button onClick={() => refetch()}>Get user</button>
    </div>
  );
}