0

I am attempting to wrap useQuery to centralize my use of it. However, the issue I am running into is the queryFn is built at runtime, so when wrapped in my custom hook, I have to conditionally return the hook's promise based on whether the queryFn is ready or not. This breaks the rules of hooks. Does anyone have information on how to properly wrap the useQuery in my custom hook? Code in it's current state is below. The main bit to look at is the return and how queryFn is being set. That's the crux of the issue.

import {
  QueryFunction,
  QueryKey,
  UseQueryOptions,
  UseQueryResult,
  useQuery,
} from "@tanstack/react-query";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import {
  ApiQueryConfig,
  QueryPathParamsType,
  QueryReturnType,
  QueryUrlParamsType,
  useApiClient,
} from "@api";
import { combineQueryKey } from "./utils";
import { useEffect, useState } from "react";

const useApiQuery = <
  T extends ApiQueryConfig<any, Record<string, string>, Record<string, any>>,
  ReturnType extends QueryReturnType<T>,
  PathParamsType extends QueryPathParamsType<T>,
  UrlParamsType extends QueryUrlParamsType<T>
>(
  apiQueryConfig: ApiQueryConfig<ReturnType, PathParamsType, UrlParamsType>,
  pathParams?: PathParamsType,
  urlParams?: UrlParamsType,
  axiosRequestConfig?: AxiosRequestConfig,
  tanstackConfig?: UseQueryOptions<
    AxiosResponse<ReturnType>,
    Error,
    AxiosResponse<ReturnType>,
    QueryKey
  >
): UseQueryResult<AxiosResponse<ReturnType, any>, Error> => {
  const apiClient = useApiClient();
  const [queryFn, setQueryFn] = useState<
    QueryFunction<AxiosResponse<ReturnType, any>> | undefined
  >(undefined);

  const axiosConfigNonOverridable = {
    params: urlParams || {},
  };
  const axiosConfigOverridable: AxiosRequestConfig = {
    timeout: 10 * 1000,
  };
  const mergedAxiosRequestConfig: AxiosRequestConfig = {
    ...axiosConfigOverridable,
    ...(axiosRequestConfig || {}),
    ...axiosConfigNonOverridable,
  };

  const tanstackConfigNonOverridable: typeof tanstackConfig = {
    enabled: !!apiClient && (tanstackConfig?.enabled || true),
  };
  const tanstackConfigOverridable: typeof tanstackConfig = {
    networkMode: "online",
    retry: 2,
    retryOnMount: true,
    staleTime: Infinity,
    cacheTime: 10 * 60 * 1000,
    refetchOnMount: true,
    refetchOnWindowFocus: false,
    refetchOnReconnect: true,
  };
  const mergedTanstackConfig: typeof tanstackConfig = {
    ...tanstackConfigOverridable,
    ...(tanstackConfig || {}),
    ...tanstackConfigNonOverridable,
  };

  const path = pathParams
    ? Object.entries(pathParams).reduce(
        (accPath, [key, value]) => accPath.replace(`{${key}}`, value),
        apiQueryConfig.apiPath
      )
    : apiQueryConfig.apiPath;

  const queryKey = combineQueryKey(
    apiQueryConfig.queryKey.baseQueryKey,
    { ...pathParams, ...urlParams },
    apiQueryConfig.queryKey.dynamicQueryKey
  );

  useEffect(() => {
    if (apiClient) {
      console.log(apiClient);
      setQueryFn(() => apiClient!.get(path, mergedAxiosRequestConfig));
    }
    // We should not use exhaustive deps here. Deps should be intentional.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [apiClient]);

  if (!queryFn) {
    return { isLoading: true } as UseQueryResult<
      AxiosResponse<ReturnType, any>,
      Error
    >;
  }

  return useQuery<AxiosResponse<ReturnType>, Error>(
    queryKey,
    queryFn!,
    mergedTanstackConfig
  );
};

export { useApiQuery };
steventnorris
  • 5,656
  • 23
  • 93
  • 174
  • Can you make an example here? https://codesandbox.io – Paulo Fernando Jun 21 '23 at 23:01
  • 1
    Every hook needs to be called every time. The hooks you are using need to provide a way to "be neutral" -- You probably should use multiple smaller custom hooks, and combine them into your main `useApiQuery` hook. Like `useQueryFunction` which returns a function to be used with `useQuery` (and/or other information), possibly an "always succeed with null" function ... ? I don't know `useQuery`, but it probably has a way to return "nothing" as quick as possible. – kca Jun 22 '23 at 19:01
  • @kca useQuery is tanstack and functions similar to rtkquery. There isn't a "return nothing" with useQuery AFAIK. I did, however, find a solution where I don't need to use my useApiClient hook and can access the api client without the hook. That got this working, but it's not really a solution to the questions, so I'm not posting it as an answer for that reason. – steventnorris Jun 23 '23 at 00:55

1 Answers1

1

I agree with @kca's comment, you should consider using multiple smaller hooks. And the way to return "nothing" is by setting enable: true.

I've made some suggestion changes. Most notably I've replaced the queryFn state with a constant and set enabled: false if queryFn === undefined.

const useApiQuery = <
  T extends ApiQueryConfig<any, Record<string, string>, Record<string, any>>,
  ReturnType extends QueryReturnType<T>,
  PathParamsType extends QueryPathParamsType<T>,
  UrlParamsType extends QueryUrlParamsType<T>
>(
  apiQueryConfig: ApiQueryConfig<ReturnType, PathParamsType, UrlParamsType>,
  pathParams?: PathParamsType,
  urlParams?: UrlParamsType,
  axiosRequestConfig?: AxiosRequestConfig,
  tanstackConfig?: UseQueryOptions<
    AxiosResponse<ReturnType>,
    Error,
    AxiosResponse<ReturnType>,
    QueryKey
  >
): UseQueryResult<AxiosResponse<ReturnType, any>, Error> => {
  const apiClient = useApiClient();

  const axiosConfigNonOverridable = {
    params: urlParams || {},
  };
  const axiosConfigOverridable: AxiosRequestConfig = {
    timeout: 10 * 1000,
  };
  const mergedAxiosRequestConfig: AxiosRequestConfig = {
    ...axiosConfigOverridable,
    ...(axiosRequestConfig || {}),
    ...axiosConfigNonOverridable,
  };

  // move `path` and `queryFn` here so we know the status
  // of `queryFn` before gathering the tanstack config
  const path = pathParams
    ? Object.entries(pathParams).reduce(
        (accPath, [key, value]) => accPath.replace(`{${key}}`, value),
        apiQueryConfig.apiPath
      )
    : apiQueryConfig.apiPath;

  // `queryFn` is directly dependent on `apiClient`, `path` and 
  // `mergedAxiosRequestChanges`, therefore it shouldn't be a state
  const queryFn = apiClient
    ? () => apiClient.get(path, mergedAxiosRequestConfig)
    : undefined;

  const tanstackConfigNonOverridable: typeof tanstackConfig = {
    enabled:
      !!apiClient &&
      // disable the query if we do not have the `queryFn`
      !!queryFn &&
      // check for exactly false, the previous implementation
      // would have `false` replaced with `true` every time
      (tanstackConfig?.enabled !== false),
  };
  const tanstackConfigOverridable: typeof tanstackConfig = {
    networkMode: "online",
    retry: 2,
    retryOnMount: true,
    staleTime: Infinity,
    cacheTime: 10 * 60 * 1000,
    refetchOnMount: true,
    refetchOnWindowFocus: false,
    refetchOnReconnect: true,
  };
  const mergedTanstackConfig: typeof tanstackConfig = {
    ...tanstackConfigOverridable,
    ...(tanstackConfig || {}),
    ...tanstackConfigNonOverridable,
  };

  const queryKey = combineQueryKey(
    apiQueryConfig.queryKey.baseQueryKey,
    { ...pathParams, ...urlParams },
    apiQueryConfig.queryKey.dynamicQueryKey
  );

  const res = useQuery<AxiosResponse<ReturnType>, Error>(
    queryKey,
    // instead of non-null assertion (`!`), we might be better of returning
    // an explicit error if we accidentally end up in that forbidden state
    queryFn || () => Promise.reject(new Error("Missing query function")),
    mergedTanstackConfig
  );

  return {
    ...res,
    // overwrite `isLoading` with `true` while `apiClient` is loading
    isLoading: res.isLoading || !apiClient
};
Anton
  • 1,045
  • 1
  • 7
  • 16
  • Thats actually really clever. For some reason my brain didn't think of overriding some of the response as well as further augmenting that enabled bit. – steventnorris Jul 21 '23 at 16:36