A way to memoize Promises

It's not rare in frontend development that multiple parts of a page request the same data from a remote API. To avoid duplicate requests, you may want to factor out the request to a state manager. Then, the question comes to whose responsibility is it to initialize the API call? The natural tendency is to call it when the root component loads, which is standard practice, but I'd argue that it's less maintainable. If, for some reason, you later need to remove all the components that need the data, then you have to remove the API call that lives somewhere else as well. I'd like to propose a better approach.

Instead of delegating the network request work to the outer world, each component should make its own request. We can avoid duplicate requests by memoizing the result of the first request.

Here's how we can memoize the result of a promise-returning function:

function memoPromise(asyncFn, expiresIn) {
  const memo = {}
  const statusMap = {}
  const resolves = []
  const rejects = []

  return async function memoizedFn(...args) {
    if (args.length === 0) { 
      throw new Error(
        "Must provide at least one serializable argument to generate an unique cache key"
      )
    }

    const memoKey = args.toString()

    if (memo[memoKey]) {
      return Promise.resolve(memo[memoKey]())
    }

    if (statusMap[memoKey] === "pending") {
      return new Promise((_res, _rej) => {
        resolves.push(_res)
        rejects.push(_rej)
      })
    }

    try {
      statusMap[memoKey] = "pending"
      const result = await asyncFn(...args)
      statusMap[memoKey] = "success"
      memo[memoKey] = function get() {
        if (typeof expiresIn === "number" && expiresIn > 0) {
          setTimeout(() => {
            memo[memoKey] = null
            statusMap[memoKey] = null
          }, expiresIn)
        }

        return result
      }
      resolves.forEach(res => res(result))
    } catch (err) {
      statusMap[memoKey] = "error"
      rejects.forEach(rej => rej(err))
      throw err
    }

    return memo[memoKey]()
  }
}

memoPromise is a higher order function that receives a promise-returning function and returns a new function. If we call the new function multiple times simultaneously, the calls after the first one will return a promise that resolves the result fetched by the first call, or rejects if the first call fails.

Optionally, you can pass a expire number to invalidate the memoized result after some time.

Let's put memoPromise to use and make a memoized fetch that invalidate the result after 5 seconds:

// utils.js
export const memoizedFetch = memoPromise(fetch, 5000)

Then we can call the same API in different components without worrying about duplicate requests:

// component 1
import { memoizedFetch } from './utils'

const result1 = await memoizedFetch('https://fakeapi.com/end-point').then(res => res.json())
// component 2
import { memoizedFetch } from './utils'

const result2 = await memoizedFetch('https://fakeapi.com/end-point').then(res => res.json())

14