Creating a Shared and Cached Fetch Request

There are cases when you have multiple components that display the same data from the same source. If every component that requests the data needs to fetch it from the source, you would end up with too many fetch requests; thus consuming many of the user network resources.

Typically, this can be solved by requesting the data only once on the parent component and then passing down the data to the components that might need it. However, now you have to responsibly manage the state of the data on the parent component and pass it around.

What if we could reuse the same code, that it is written like every component is requesting the data, but the data was actually shared and cached instead of fetching it every time from the source?

The concept is simple, wrap the fetch call into a function that handles sharing fetch state and cache expiration.

// Simulate network call
function fetch(url: string): Promise<{ expired: boolean; url: string }> {
  console.log(`NETWORK CALL (${url})`);
  const randomDelay = Math.round(Math.random() * 1000 + 100);
  return new Promise((res, rej) => {
    setTimeout(
      () =>
        res({
          expired: true,
          url
        }),
      randomDelay
    );
  });
}

// To wrap the fetch function into a cached and shared version
function cached<T>(
  fn: (...args: any[]) => Promise<T>,
  expire: number,
  args: any[]
): () => Promise<T> {
  let cache: any = null;
  let promise: Promise<T> | null = null;
  let fetcher: Promise<T | void> | null = null;
  return () => {
    // If the network call is processing, we reuse the promise
    if (fetcher && promise) {
      return promise;
    }
    // We store the promise that wraps the caching and network call
    // so we can reuse it later in case the network call hasn't finished
    promise = new Promise((res, rej) => {
      // Automatically resolve to cache when it is available
      if (cache) {
        res(cache);
        return;
      }
      // Store the fetcher so we know that the network call is still
      // processing
      fetcher = fn.apply(null, args).then(value => {
        cache = value;
        res(cache);
        // Clear the reusable promise and mark network call finished
        fetcher = null;
        promise = null;
      });
      // Clear the cache once its expired
      setTimeout(() => (cache = null), expire);
    });
    return promise;
  };
}

// Store the shared and cached version of fetch
// Components that accesses the shared data should use this
const f1 = cached(fetch, 100, ["https://api.example.com/subcription-expired"]);

f1().then(v => console.log(`${v.url} - ${v.expired}`));
f1().then(v => console.log(`${v.url} - ${v.expired}`));

You can play with this example on CodeSandbox.

Another Approach

What do you think about this approach? Do you have a better alternative or addition to this implementation? Feel free to share your thoughts!

I remember using saga patterns that handle this kind of issue, but adding a whole saga pattern seems like an overkill for my small project.

Type to search. to navigate. Enter to open. Esc to close.