0

I need to build a system to fetch a unknown number of documents (images, pdfs etc...) to a page.

I have a request that returns to me a list of documents and some metadata, which I then need to fetch so they can be displayed.

I am trying to make a custom hook to manage concurrent queries so that I can limit the number of queries being fetched simultaneously (for example 3 by 3). Here is a scaffold of the custom hook I am trying to create:

import { useQueries } from "react-query";

function useConcurrentQueries({
  queryKey,
  queryFunction,
  options,
  idList,
  concurrentQueriesLimit,
}) {
  const enabledIds = []

  const queries = useQueries(
    idList?.length > 0 && idList
      .map(id => {
        return {
          queryKey: [queryKey, id],
          queryFn: () => queryFunction(id),
          ...options,
          staleTime: Infinity,
          enabled: enabledIds.includes(id)
      }})
  );

  // additional code goes here
}

I would like as much as possible trying not to refetch(), so that, if later on I am trying to query something that is already in the cache, it wont do so (those documents are not going to change overtime and have a staleTime of Infinity.

Is this the right approach to achieve what I am trying to do ?

How would you guys solve this problem ?

Cheers folks ^^

I tried looking up this problem on Google, using BingAi and reading through the TanStack documentation but I can't seem to find a solution...

ToM
  • 3
  • 2

2 Answers2

0

I have a similar problem and have been experimenting with the use of a "semaphore" to limit the amount of enable queries.

First, the definition of said Semaphore:

export default class Semaphore {
  private concurrentCalls: number
  private waitingQueue: Array<() => void>

  constructor(concurrentCalls: number) {
    this.concurrentCalls = concurrentCalls
    this.waitingQueue = []
  }

  async acquire(): Promise<void> {
    return new Promise<void>((resolve) => {
      if (this.concurrentCalls > 0) {
        this.concurrentCalls--
        resolve()
      } else {
        this.waitingQueue.push(resolve)
      }
    })
  }

  release(): void {
    if (this.waitingQueue.length > 0) {
      this.waitingQueue.shift()!()
    } else {
      this.concurrentCalls++
    }
  }
}

And now your custom useQuery hook (consider this as pseudocode as I modified it from the original):

export default function useSingleQuery(data) {
  const semaphore = new Semaphore(1) // Allow only one search at a time
  const [enabledBySemaphore, setEnabledBySemaphore] = useState(false)
  const isFetching = useIsFetching()


  useEffect(() => {

    async function acquire(): Promise<void> {
      await semaphore.acquire()
      setEnabledBySemaphore(true)
    }
    async function release(): Promise<void> {
      await semaphore.release()
      setEnabledBySemaphore(false)
    }

    isFetching === 0 ? acquire() : release()
    }


  }, [isFetching])


return useQuery(
    ['yourQueryName', params],
    async () => {
      try {
        const res = await yourQueryFn()
        return res
      } catch (error) {
        throw new Error(
          `Error fetching "yourQueryName": ${error}`
        )
      }
    },
    {
      cacheTime: 1000 * 60 * 60 * 15,
      staleTime: 1000 * 60 * 60 * 15,
      enabled: enabledBySemaphore
    }
  )
}

This example does limit the number of parallel queries active in the scope of a single component, but I am having trouble keeping the limitation when multiple components are mounted, even if I save the semaphore instance to global state.

Would love to hear some opinions from people who eventually solved this.

18_
  • 11
  • 5
0

After tinkering for a while, I have found a solution that seems to work okay, regardless of the staleTime, but I am not quite sure about the performance of my function...

Here it goes:

type useConcurrentQueriesProps<T> = {
  queryKey: string;
  queryFn: (id: unknown) => Promise<T>;
  options?: UseQueryOptions<T>;
  idList: unknown[];
  concurrentQueriesLimit: number;
};

export default function useConcurrentQueries<T>({
  queryKey,
  queryFn,
  options,
  idList,
  concurrentQueriesLimit,
}: useConcurrentQueriesProps<T>): UseQueryResult<
  unknown extends T ? T : T,
  unknown
>[] {
  // A ref to keep a list of the ids to be removed as they are fetched
  const idsRemainingRef = useRef<unknown[]>([]);

  // A ref to indicate which are the current batch ids
  const currentBatchIdsRef = useRef<unknown[]>([]);

  // A ref to indicate if it's the first render
  const firstRenderRef = useRef(true);

  // This function initializes the refs on first render.
  // Probably because the useQueries hook is async, the refs won't initialize above
  const init = () => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      idsRemainingRef.current = idList;
      currentBatchIdsRef.current = idList.slice(0, concurrentQueriesLimit);
    }
  };

  // build of the array of queries using useQueries
  const queries = useQueries(
    idList.map((id) => {
      init();
      return {
        queryKey: [queryKey, id],
        queryFn: () => queryFn(id),
        ...options,
        onSuccess: (data: T) => {
          if (options?.onSuccess) options.onSuccess(data);

          // remove the current id from the idsRemainingRef
          idsRemainingRef.current = idsRemainingRef.current.filter(
            (idRemaining) => idRemaining !== id,
          );

          // if there aren't any remaining unsuccessful queries in the batch,
          // selects the next batch
          idsRemainingRef.current.forEach((idRemaining) => {
            if (!currentBatchIdsRef.current.includes(idRemaining)) {
              currentBatchIdsRef.current = [
                ...idsRemainingRef.current.slice(0, concurrentQueriesLimit),
              ];
            }
          });

          // if there aren't any remaining ids, we clear the last batch
          if (idsRemainingRef.current.length === 0) {
            currentBatchIdsRef.current = [];
          }
        },
        enabled:
          // if the query is disabled in the given options, we disable it
          options?.enabled === false
            ? false
            // we enable the query if its id is included in the currentBatch
            : currentBatchIdsRef.current.includes(id),
      };
    }),
  );
  return queries;
}

And here is the implementation:

function useGetDocuments(documentIds: number[]) {
  return useConcurrentQueries<Document>({
    queryKey: "documents",
    queryFn: (id) =>
      fetch(`http://a-real-url/${id}`).then((res) => res.json()),
    idList: documentIds,
    concurrentQueriesLimit: 3,
  });
}

A console.log in the body of the useConcurrentQueries hook shows a great number of re-renders which I doubt is a good sign.

Any improvement on this would be welcome.

ToM
  • 3
  • 2