Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

suspense is not working for useQueries #1523

Closed
wenerme opened this issue Dec 25, 2020 · 11 comments · Fixed by #4498
Closed

suspense is not working for useQueries #1523

wenerme opened this issue Dec 25, 2020 · 11 comments · Fixed by #4498
Labels
bug Something isn't working suspense

Comments

@wenerme
Copy link

wenerme commented Dec 25, 2020

Describe the bug

To Reproduce

const DemoA = () => {
  return (
    <Suspense fallback={'loading'}>
      <Works />
    </Suspense>
  );
};
const DemoB = () => {
  return (
    <Suspense fallback={'loading'}>
      <NotWork />
    </Suspense>
  );
};
const Works: React.FC = () => {
  // status success success success
  console.log(
    'status',
    [1, 2, 3]
      .map(
        (v) =>
          ({
            queryKey: ['users', v],
            queryFn: fetchApi,
            suspense: true,
          } as UseQueryOptions),
      )
      .map(useQuery)
      .map(({ status }) => status),
  );

  return null;
};
const NotWork: React.FC = () => {
  // status loading loading loading
  // status success loading loading
  // status success success loading
  // status success success success
  console.log(
    'status',
    useQueries(
      [1, 2, 3].map(
        (v) =>
          ({
            queryKey: ['users', v],
            queryFn: fetchApi,
            suspense: true,
          } as UseQueryOptions),
      ),
    ).map(({ status }) => status),
  );
  return null;
};

Expected behavior
useQueries should works like the useQuery loop, the result status should be success

Screenshots

Desktop (please complete the following information):

Additional context

@tannerlinsley
Copy link
Collaborator

Confirmed. We'll look into this.

@jezzgoodwin
Copy link

Hi, I just wondered if there is an ETA for this bug to be looked at? I'm currently using a hacky workaround but it would be great to get a proper fix. Many thanks for the library.

@its-miller-time
Copy link

Hi, I just wondered if there is an ETA for this bug to be looked at? I'm currently using a hacky workaround but it would be great to get a proper fix. Many thanks for the library.

Im interested in what your workaround is....I came up with one as well but it doenst work in a new use case i have.

@jezzgoodwin
Copy link

Hi, I just wondered if there is an ETA for this bug to be looked at? I'm currently using a hacky workaround but it would be great to get a proper fix. Many thanks for the library.

Im interested in what your workaround is....I came up with one as well but it doenst work in a new use case i have.

Hi. My workaround is to create an async function which calls multiple APIs at once using Promise.all. And then call this function from React Query. It works but misses out on a lot of the power of React Query if each api call was handled separately.

@TkDodo TkDodo changed the title suspense not works for useQueries suspense is not working for useQueries Nov 6, 2021
@ag-m2ms
Copy link

ag-m2ms commented Feb 27, 2022

Hi, sorry for necrobumping this issue, but for people who are looking for a workaround (until v4 is released) while keeping all of the power of react-query, we think we've came up with an acceptable solution. useQuery does suspend but you can't dynamically create useQuery hook calls, what you can dynamically create, though, is components which use useQuery. For each query in useQueries you can dynamically create a component, which calls useQuery with the provided query and renders nothing. To avoid manually having to create it, here's our solution using Context API.

Let's assume you have a custom component which you use to wrap your component tree with Suspense:

export const SuspenseWithBoundary = forwardRef(({ children, SuspenseProps = {}, ErrorBoundaryProps = {} }, ref) => {
  return (
    <ErrorBoundary
      ref={ref}
      onError={error => console.log(error.message)}
      fallbackRender={({ error }) => {
        return (
          <Alert severity="error">
            <AlertTitle>Error</AlertTitle>
            {error.message}
          </Alert>
        );
      }}
      {...ErrorBoundaryProps}
    >
      <Suspense fallback={<LoadingSpinner />} {...SuspenseProps}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
});

Let's create a context and a component which will receive a list of queries and then renders them under the Suspense component:

export const SuspendQueriesContext = createContext({
  addQueries: (id, queries) => {},
  removeQueries: id => {}
});
export const QueriesSuspender = ({ children }) => {
  // Store queries in a map where keys are IDs of the querying hooks
  const [queriesMap, setQueriesMap] = useState({});

  const addQueries = useCallback((id, queries) => {
    setQueriesMap(prevQueriesMap => ({
      ...prevQueriesMap,
      [id]: queries
    }));
  }, []);

  const removeQueries = useCallback(id => {
    setQueriesMap(prevQueriesMap => {
      const newQueriesMap = { ...prevQueriesMap };
      delete newQueriesMap[id];
      return newQueriesMap;
    });
  }, []);

  const value = useMemo(
    () => ({
      addQueries,
      removeQueries
    }),
    [addQueries, removeQueries]
  );

  return (
    <SuspendQueriesContext.Provider value={value}>
      {children}
      {Object.entries(queriesMap).map(([id, queries]) =>
        queries.map((query, index) => <QueryWrapper key={`${id}-${index}`} query={query} />)
      )}
    </SuspendQueriesContext.Provider>
  );
};

The QueryWrapper component looks like this:

export const QueryWrapper = ({ query }) => {
  useQuery(query);

  return null;
};

Add your newly created component to your custom Suspense component:

export const SuspenseWithBoundary = forwardRef(({ children, SuspenseProps = {}, ErrorBoundaryProps = {} }, ref) => {
  return (
    <ErrorBoundary
      ref={ref}
      onError={error => console.log(error.message)}
      fallbackRender={({ error }) => {
        return (
          <Alert severity="error">
            <AlertTitle>Error</AlertTitle>
            {error.message}
          </Alert>
        );
      }}
      {...ErrorBoundaryProps}
    >
      <Suspense fallback={<LoadingSpinner />} {...SuspenseProps}>
        <QueriesSuspender>{children}</QueriesSuspender>
      </Suspense>
    </ErrorBoundary>
  );
});

To add and remove queries which are to be rendered let's create a hook:

let generator = 0;

export const useSuspendQueries = queries => {
  // Each time queries change, generate a new ID. The reason for generating a new one once queries change is that
  // we can uniquely identify each query when rendering it in QueriesSuspender component.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const id = useMemo(() => ++generator, [queries]);

  const { addQueries, removeQueries } = useContext(SuspendQueriesContext);

  // When id and queries change (they change at the same time), remove previous queries with previous ID and add
  // new queries with a new ID. useLayoutEffect is used to immediately mount the queries into the tree so the
  // the component tree immediately suspends if the queries haven't been resolved yet.
  useLayoutEffect(() => {
    addQueries(id, queries);

    return () => removeQueries(id);
  }, [id, addQueries, removeQueries, queries]);
};

Finally, create on more hook which you will use instead of using useQueries:

export const useSuspendingQueries = queries => {
  useSuspendQueries(queries);
  return useQueries(queries);
};

And this is how you use it, for example:

const results = useSuspendingQueries(
  useMemo(() => {
    return (
      targets?.map(target => {
        const queryKey = getMethodsQueryKey(target.id);

        return {
          queryKey,
          queryFn: () => axiosGet(queryKey)
        };
      }) || []
    );
  }, [targets])
);

The API is identical to the one useQueries uses, you don't have to add any other code. The mechanism automatically creates components which call useQuery for each query passed in as a parameter in the useSuspendingQueries and suspends the tree. Keep in mind, thought, that the array of queries passed should be memoized.

@TkDodo
Copy link
Collaborator

TkDodo commented Mar 3, 2022

For each query in useQueries you can dynamically create a component, which calls useQuery with the provided query and renders nothing.

I don't think this can really work as we intended it to work with useQueries. Have a look at the proposal over here.

Basically, what we wanted to do is have multiple queries fire off at the same time, and then suspend until at least one (or all) have returned.

The solution with multiple useQuery instances that all have suspense:true turns that into a waterfall fetching:

  • The first component mounts, triggers a query, we throw the Promise, which suspends
  • This means the second component doesn't get a chance to start fetching!
  • Once the first component finishes, suspense "continues", the second component can render, we throw the Promise again, so we suspend again.

This goes a lot against what useQueries was supposed to do, which is parallel fetching.

I really like the ideas that @lubieowoce has had in that regard, and I'm hoping that she will find the time to contribute that. It's likely not easy to implement, especially the mixed version where some queries suspend and some don't. The new useQueries api with the object syntax is merged already to v4, so I think everything that we'll add in that regard can be made backwards compatible, given that suspense doesn't work at all at the moment for useQueries.

@ag-m2ms
Copy link

ag-m2ms commented Mar 4, 2022

Basically, what we wanted to do is have multiple queries fire off at the same time, and then suspend until at least one (or all) have returned.

The solution with multiple useQuery instances that all have suspense:true turns that into a waterfall fetching:

  • The first component mounts, triggers a query, we throw the Promise, which suspends
  • This means the second component doesn't get a chance to start fetching!
  • Once the first component finishes, suspense "continues", the second component can render, we throw the Promise again, so we suspend again.

This goes a lot against what useQueries was supposed to do, which is parallel fetching.

Hi, thanks for the response. I wouldn't say it leads to a waterfall. The reason is that apart from using useQuery, you use useQueries as well. Even if the first mounted component suspends, useQueries still fires off the remaining queries. It keeps mounting the useQuery components - if it arrives at one which hasn't been fetched it suspends and the process keeps repeating unless all of the queries are resolved.

Here's a screenshot from devtools. We firstly fetch a list of targets, then for each target its list of methods and for each method its list of reactions. I wouldn't say there's a waterfall.

image

I am not saying this solution is ideal, it's just a temporary workaround which we found working for us fairly well and wanted to share with others if they are looking for one. I suspect there might be a slight performance hit doing it this way.

@nucleartux
Copy link
Contributor

nucleartux commented Aug 21, 2022

Hello,

I wrote drop in replacement for useQueries (v4) with suspense support. It is far from perfect (we are accessing private observers property) but it works! So you can use it right now.

I hope someone can refactor code and make PR.

import * as React from "react";
import { useSyncExternalStore } from "use-sync-external-store/shim";

import {
  QueryKey,
  QueryFunction,
  notifyManager,
  QueriesObserver,
} from "@tanstack/query-core";
import {
  useQueryClient,
  useIsRestoring,
  UseQueryOptions,
  UseQueryResult,
  useQueryErrorResetBoundary,
  QueryObserver,
} from "@tanstack/react-query";

// This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`.
// - `context` is omitted as it is passed as a root-level option to `useQueries` instead.
type UseQueryOptionsForUseQueries<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, "context">;

// Avoid TS depth-limit error in case of large array literal
type MAXIMUM_DEPTH = 20;

type GetOptions<T> =
  // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData }
  T extends {
    queryFnData: infer TQueryFnData;
    error?: infer TError;
    data: infer TData;
  }
    ? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData>
    : T extends { queryFnData: infer TQueryFnData; error?: infer TError }
    ? UseQueryOptionsForUseQueries<TQueryFnData, TError>
    : T extends { data: infer TData; error?: infer TError }
    ? UseQueryOptionsForUseQueries<unknown, TError, TData> // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData]
    : T extends [infer TQueryFnData, infer TError, infer TData]
    ? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData>
    : T extends [infer TQueryFnData, infer TError]
    ? UseQueryOptionsForUseQueries<TQueryFnData, TError>
    : T extends [infer TQueryFnData]
    ? UseQueryOptionsForUseQueries<TQueryFnData> // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided
    : T extends {
        queryFn?: QueryFunction<infer TQueryFnData, infer TQueryKey>;
        select: (data: any) => infer TData;
      }
    ? UseQueryOptionsForUseQueries<TQueryFnData, unknown, TData, TQueryKey>
    : T extends { queryFn?: QueryFunction<infer TQueryFnData, infer TQueryKey> }
    ? UseQueryOptionsForUseQueries<
        TQueryFnData,
        unknown,
        TQueryFnData,
        TQueryKey
      > // Fallback
    : UseQueryOptionsForUseQueries;

type GetResults<T> =
  // Part 1: responsible for mapping explicit type parameter to function result, if object
  T extends { queryFnData: any; error?: infer TError; data: infer TData }
    ? UseQueryResult<TData, TError>
    : T extends { queryFnData: infer TQueryFnData; error?: infer TError }
    ? UseQueryResult<TQueryFnData, TError>
    : T extends { data: infer TData; error?: infer TError }
    ? UseQueryResult<TData, TError> // Part 2: responsible for mapping explicit type parameter to function result, if tuple
    : T extends [any, infer TError, infer TData]
    ? UseQueryResult<TData, TError>
    : T extends [infer TQueryFnData, infer TError]
    ? UseQueryResult<TQueryFnData, TError>
    : T extends [infer TQueryFnData]
    ? UseQueryResult<TQueryFnData> // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided
    : T extends {
        queryFn?: QueryFunction<unknown, any>;
        select: (data: any) => infer TData;
      }
    ? UseQueryResult<TData>
    : T extends { queryFn?: QueryFunction<infer TQueryFnData, any> }
    ? UseQueryResult<TQueryFnData> // Fallback
    : UseQueryResult;

/**
 * QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param
 */
export type QueriesOptions<
  T extends any[],
  Result extends any[] = [],
  Depth extends ReadonlyArray<number> = []
> = Depth["length"] extends MAXIMUM_DEPTH
  ? UseQueryOptionsForUseQueries[]
  : T extends []
  ? []
  : T extends [infer Head]
  ? [...Result, GetOptions<Head>]
  : T extends [infer Head, ...infer Tail]
  ? QueriesOptions<[...Tail], [...Result, GetOptions<Head>], [...Depth, 1]>
  : unknown[] extends T
  ? T // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogenous type! // use this to infer the param types in the case of Array.map() argument
  : T extends UseQueryOptionsForUseQueries<
      infer TQueryFnData,
      infer TError,
      infer TData,
      infer TQueryKey
    >[]
  ? UseQueryOptionsForUseQueries<TQueryFnData, TError, TData, TQueryKey>[] // Fallback
  : UseQueryOptionsForUseQueries[];

/**
 * QueriesResults reducer recursively maps type param to results
 */
export type QueriesResults<
  T extends any[],
  Result extends any[] = [],
  Depth extends ReadonlyArray<number> = []
> = Depth["length"] extends MAXIMUM_DEPTH
  ? UseQueryResult[]
  : T extends []
  ? []
  : T extends [infer Head]
  ? [...Result, GetResults<Head>]
  : T extends [infer Head, ...infer Tail]
  ? QueriesResults<[...Tail], [...Result, GetResults<Head>], [...Depth, 1]>
  : T extends UseQueryOptionsForUseQueries<
      infer TQueryFnData,
      infer TError,
      infer TData,
      any
    >[] // Dynamic-size (homogenous) UseQueryOptions array: map directly to array of results
  ? UseQueryResult<unknown extends TData ? TQueryFnData : TData, TError>[] // Fallback
  : UseQueryResult[];

export function useQueries<T extends any[]>({
  queries,
  context,
  options,
}: {
  queries: readonly [...QueriesOptions<T>];
  context?: UseQueryOptions["context"];
  options: { suspense?: boolean };
}): QueriesResults<T> {
  const queryClient = useQueryClient({ context });
  const isRestoring = useIsRestoring();
  const errorResetBoundary = useQueryErrorResetBoundary();

  const defaultedQueries = React.useMemo(
    () =>
      queries.map((options) => {
        const defaultedOptions = queryClient.defaultQueryOptions(options);

        // Make sure the results are already in fetching state before subscribing or updating options
        defaultedOptions._optimisticResults = isRestoring
          ? "isRestoring"
          : "optimistic";

        return defaultedOptions;
      }),
    [queries, queryClient, isRestoring]
  );

  const [observer] = React.useState(
    () => new QueriesObserver(queryClient, defaultedQueries)
  );

  const result = observer.getOptimisticResult(defaultedQueries);

  useSyncExternalStore(
    React.useCallback(
      (onStoreChange) =>
        isRestoring
          ? () => undefined
          : observer.subscribe(notifyManager.batchCalls(onStoreChange)),
      [observer, isRestoring]
    ),
    () => observer.getCurrentResult(),
    () => observer.getCurrentResult()
  );

  React.useEffect(() => {
    // Do not notify on updates because of changes in the options because
    // these changes should already be reflected in the optimistic result.
    observer.setQueries(defaultedQueries, { listeners: false });
  }, [defaultedQueries, observer]);

  const isLoading = result.some((r) => r.isLoading);

  const isFetching = result.some((r) => r.isFetching);

  if (options.suspense && isLoading && isFetching && !isRestoring) {
    throw Promise.all(
      (observer as any as { observers: QueryObserver[] }).observers.map(
        (o, i) =>
          o.fetchOptimistic({
            ...defaultedQueries[i],
            staleTime: defaultedQueries[i].staleTime || 1000,
          })
      )
    ).catch(() => {
      errorResetBoundary.clearReset();
    });
  }

  return result as QueriesResults<T>;
}

@TkDodo
Copy link
Collaborator

TkDodo commented Nov 12, 2022

It took quite some time but I think I have a working version. Please feel free to try it out and provide feedback. It's sill experimental, so don't expect too much 😅

@teddybee
Copy link

teddybee commented Feb 5, 2023

@TkDodo are you planning to trigger suspense with isFetching state of a query?

@TkDodo
Copy link
Collaborator

TkDodo commented Feb 12, 2023

No

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working suspense
Projects
None yet
8 participants