reactjstypescriptreact-hookscompiler-errorsrefactoring

Custom hook inside custom hook pattern


I use Tanstack Query in my project and have arranged my api interactions like this (see below). Basically I made a custom hook inside which there are other hooks that do data fetching. In my opinion, it makes it all look organized instead of having each hook exported separately.

It all works perfectly fine except that I can't pass eslint-plugin-react-compiler linting. It says: Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hookseslint(react-compiler/react-compiler) when I destructure one of the hooks inside a component like this:

  const { CheckLogin } = useAuthQuery();
  const {
    data: CheckLoginData,
    mutateAsync: CheckLoginMutation,
    isPending: isCheckLoginPending,
    reset,
  } = CheckLogin();

Is this an anti-pattern or I can satisfy eslint without redoing all of this back to individual exports?

export const useAuthQuery = () => {
  const { auth } = useAuth();
  const { customKy } = useKy();

  const getUser = async (login: string) => {
    const users: user[] = await customKy.get(USERS).json();
    const user = users.find((user) => user.username === login);

    return user;
  };

  const useGetUser = () => {
    return useQuery({
      queryKey: ["user", auth?.username],
      queryFn: async () => {
        if (!auth?.username) return;
        const user = await getUser(auth.username);
        return user;
      },
    });
  };

  const CheckLogin = () =>
    useMutation({
      mutationKey: ["CheckLogin"],
      mutationFn: async (
        login: string
      ): Promise<CheckLoginResponse | undefined> => {
        const user = await getUser(login);
        console.log(user);

        if (!user) throw new Error("User not found!");
        return user;
      },
      gcTime: 0,
    });

  const LoginByPassword = () => {
    return useMutation({
      mutationFn: async (credentials: {
        login: string;
        password: string;
      }): Promise<loginResponse> => {
        const user = await getUser(credentials.login);
        console.log(user);

        if (!user) throw new Error("User not found!");

        return await customKy
          .post(LOGIN, {
            json: { email: user?.email, password: credentials.password },
          })
          .json();
      },
      gcTime: 0,
    });
  };

  const LoginByCode = () => {
    return useMutation({
      mutationFn: async (credentials: { login: string; code: string }) => {
        const user = await getUser(credentials.login);

        if (Number(user?.otp) === Number(credentials.code)) {
          const userData = await customKy
            .post<loginResponse>(LOGIN, {
              json: { email: user?.email, password: user?.realPassword },
            })
            .json();
          return userData;
        } else {
          throw new Error("wrong code");
        }
      },
      gcTime: 0,
    });
  };

  const RequestCode = () => {
    return useMutation({
      mutationFn: async (codeData: { otp: number; login: string }) => {
        const user = await getUser(codeData.login);
        return customKy.patch(`${USERS}/${user?.id}`, {
          json: { otp: codeData.otp },
        });
      },
      gcTime: 0,
    });
  };

  return {
    CheckLogin,
    RequestCode,
    LoginByCode,
    LoginByPassword,
    useGetUser,
  };
};

Solution

  • Is this an anti-pattern

    Correct, this is a React hooks anti-pattern. React hooks can only be called in React functions and custom React hooks; never in conditionals, loops, and callback functions.

    or I can satisfy eslint without redoing all of this back to individual exports?

    I see no way to align and follow React's Rules of Hooks with your current implementation. You'll need separate exports, or at least a single export of separately defined hooks that can be called individually.

    I'd suggest a bit of refactoring to abstract the auth, getUser, and customKy functions/references into a "utility" hook that can be called by the other functions that will be converted to hooks as well where any "external dependencies" can accessed via the utility hook instead of accessed via the Javascript closure.

    Example:

    const useQueryUtils = () => {
      const { auth } = useAuth();
      const { customKy } = useKy();
      
      const getUser = React.useCallback(async (login: string) => {
        const users: user[] = await customKy.get(USERS).json();
        const user = users.find((user) => user.username === login);
        return user;
      }, [customKy]);
    
      return { auth, customKy, getUser };
    };
    
    export const useGetUserQuery = () => {
      const { auth, getUser } = useQueryUtils();
    
      return useQuery({
        queryKey: ["user", auth?.username],
        queryFn: () => {
          if (!auth?.username) return;
          return getUser(auth.username);
        },
      });
    };
    
    export const useCheckLoginMutation = () => {
      const { getUser } = useQueryUtils();
    
      return useMutation({
        mutationKey: ["CheckLogin"],
        mutationFn: async (
          login: string
        ): Promise<CheckLoginResponse | undefined> => {
          const user = await getUser(login);
    
          if (!user) throw new Error("User not found!");
          return user;
        },
        gcTime: 0,
      });
    };
    
    export const useLoginByPasswordMutation = () => {
      const { customKy, getUser } = useQueryUtils();
    
      return useMutation({
        mutationFn: async (credentials: {
          login: string;
          password: string;
        }): Promise<loginResponse> => {
          const user = await getUser(credentials.login);
    
          if (!user) throw new Error("User not found!");
    
          return customKy
            .post(LOGIN, {
              json: { email: user?.email, password: credentials.password },
            })
            .json();
        },
        gcTime: 0,
      });
    };
    
    export const useLoginByCodeMutation = () => {
      const { customKy, getUser } = useQueryUtils();
    
      return useMutation({
        mutationFn: async (credentials: { login: string; code: string }) => {
          const user = await getUser(credentials.login);
    
          if (Number(user?.otp) === Number(credentials.code)) {
            return customKy
              .post<loginResponse>(LOGIN, {
                json: { email: user?.email, password: user?.realPassword },
              })
              .json();
          } else {
            throw new Error("wrong code");
          }
        },
        gcTime: 0,
      });
    };
    
    export const useRequestCodeMutation = () => {
      const { customKy, getUser } = useQueryUtils();
    
      return useMutation({
        mutationFn: async (codeData: { otp: number; login: string }) => {
          const user = await getUser(codeData.login);
          return customKy.patch(`${USERS}/${user?.id}`, {
            json: { otp: codeData.otp },
          });
        },
        gcTime: 0,
      });
    };
    

    If you like, you can package them all up into a single object reference

    export const authQueryHooks = {
      useGetUserQuery,
      useCheckLoginMutation,
      useLoginByPasswordMutation,
      useLoginByCodeMutation,
      useRequestCodeMutation,
    };
    

    Usages:

    const {
      data: CheckLoginData,
      mutateAsync: CheckLoginMutation,
      isPending: isCheckLoginPending,
      reset,
    } = authQueryHooks.useCheckLoginMutation();
    
    const {
      ....
    } = authQueryHooks.useLoginByPasswordMutation({
      login: "someLogin";
      password: "somePassword";
    });