Skip to the code
cf.

Dedupe in-flight requests

Write dedupe(fn, getKey). fn is an async function, such as one that fetches data. dedupe returns a function that takes the same arguments, passes all of them to fn, and always returns a promise.

While a call for a key is in flight, every other call with that key returns the same promise instead of calling fn again: ten components asking for user 7 at once cause one request, not ten.

The key is getKey(...args); without getKey, it's the first argument. Keys are compared the way Map compares them, so 1 and "1" are different keys.

Once the call settles, fulfilled or rejected, its key is forgotten, and the next call starts a fresh one: nothing is cached after that.

const getUser = dedupe((id) => fetchJson("/api/users/" + id));
 
const a = getUser(7);
const b = getUser(7); // no second request: b is the same promise as a
const c = getUser(8); // a different key, so a new request
 
await a;
getUser(7); // the first request has finished, so this starts a new one

What the tests check

  1. shares one call between callers with the same key
    const clock = useFakeTimers();
    const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, "user " + id)));
    const getUser = dedupe(fn);
    const a = getUser(7);
    const b = getUser(7);
    await flush();
    expect(fn).toHaveBeenCalledTimes(1);
    expect(b).toBe(a);
    await clock.tickAsync(100);
    await expect(b).resolves.toBe("user 7");
    
  2. makes separate calls for different keys
    const clock = useFakeTimers();
    const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, "user " + id)));
    const getUser = dedupe(fn);
    const results = [];
    getUser(7).then((value) => results.push(value));
    getUser(8).then((value) => results.push(value));
    await clock.tickAsync(100);
    expect(fn).toHaveBeenCalledTimes(2);
    expect(results).toEqual(["user 7", "user 8"]);
    
  3. starts a new call once the previous one has fulfilled
    const clock = useFakeTimers();
    const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, "user " + id)));
    const getUser = dedupe(fn);
    getUser(7);
    await clock.tickAsync(100);
    getUser(7);
    await flush();
    expect(fn).toHaveBeenCalledTimes(2);
    
  4. gives every caller the rejection when the call fails
    const clock = useFakeTimers();
    const error = new Error("server down");
    const getUser = dedupe(() => new Promise((_, reject) => setTimeout(reject, 100, error)));
    const reasons = [];
    getUser(7).catch((reason) => reasons.push(reason));
    getUser(7).catch((reason) => reasons.push(reason));
    await clock.tickAsync(100);
    expect(reasons).toHaveLength(2);
    expect(reasons[0]).toBe(error);
    expect(reasons[1]).toBe(error);
    
  5. starts a new call once the previous one has rejected
    const clock = useFakeTimers();
    const fn = spy(() => new Promise((_, reject) => setTimeout(reject, 100, new Error("server down"))));
    const getUser = dedupe(fn);
    getUser(7).catch(() => {}); // it rejects; this test only counts the calls
    await clock.tickAsync(100);
    getUser(7).catch(() => {});
    await flush();
    expect(fn).toHaveBeenCalledTimes(2);
    
  6. passes every argument to fn
    const fn = spy(async () => "ok");
    const search = dedupe(fn);
    await search("shoes", { page: 2 });
    expect(fn).toHaveBeenCalledWith("shoes", { page: 2 });
    
  7. uses getKey to compute the key
    const fn = spy(async (user) => user.id);
    const load = dedupe(fn, (user) => user.id);
    const a = load({ id: 1, name: "Ada" });
    const b = load({ id: 1, name: "Ada L." });
    expect(b).toBe(a);
    await a;
    expect(fn).toHaveBeenCalledTimes(1);
    
  8. keeps keys of different types apart, such as 1 and "1"
    const clock = useFakeTimers();
    const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, id)));
    const load = dedupe(fn);
    const a = load(1);
    const b = load("1");
    expect(b).not.toBe(a);
    await flush();
    expect(fn).toHaveBeenCalledTimes(2);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).