skip to Main Content

I’m working on a movie discovery project where I need to fetch data from two APIs (Tmdb movie site) with different response structures (movies and genres endpoints). To avoid duplicating the same logic on every hook that I use to fetch data from the API, I know I can utilize generics but unfortunately when I try I get the error undefined.

Current genres hook

import { useEffect, useState } from "react";
import apiClient from "../services/api-client";
import { CanceledError } from "axios";

interface Genre {
  id: number;
  name: string;
}

interface FetchGenreResponse {
  genres: Genre[];
}

const useGenres = () => {
  const [genres, setGenres] = useState<Genre[]>([]);
  const [error, setError] = useState();
  const [isLoading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    const controller = new AbortController();
    apiClient
      .get<FetchGenreResponse>("/genre/movie/list", {
        signal: controller.signal,
      })
      .then((res) => {
        setGenres(res.data.genres);
        setLoading(false);
      })
      .catch((err) => {
        if (err instanceof CanceledError) return;
        setLoading(false);
        setError(err);
      });

    return () => controller.abort();
  }, []);

  return { genres, error, isLoading };
};

export default useGenres;

Current movies hook

import { useEffect, useState } from "react";
import apiClient from "../services/api-client";
import { CanceledError } from "axios";

interface FetchMoviesResponse {
  total_results: number;
  results: Movie[];
}

export interface Movie {
  id: number;
  title: string;
  backdrop_path: string;
  vote_average: number;
}

const useMovies = () => {
  const [movies, setMovies] = useState<Movie[]>([]);
  const [error, setError] = useState();
  const [isLoading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    const controller = new AbortController();
    apiClient
      .get<FetchMoviesResponse>("/discover/movie", {
        signal: controller.signal,
      })
      .then((res) => {
        setMovies(res.data.results);
        setLoading(false);
      })
      .catch((err) => {
        if (err instanceof CanceledError) return;
        setLoading(false);
        setError(err);
      });

    return () => controller.abort();
  }, []);


  return { movies, error, isLoading };
};

export default useMovies;

But as you can see I’ve duplicated the same logic which is not a good practice.

I tried to extract a generic data-fetching hook to reduce redundancy, but it only works for the movies endpoint and returns undefined for genres. I believe this is because the generic hook expects a results array, which is present in the movies response but not in the genres response. The genres response uses a genres array instead.

My attempted solution

import { useEffect, useState } from "react";
import apiClient from "../services/api-client";
import { CanceledError } from "axios";

interface FetchResponse<T> {
  results: T[];
}

const useData = <T>(endpoint: string) => {
  const [data, setData] = useState<T[]>([]);
  const [error, setError] = useState<any>();
  const [isLoading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    const controller = new AbortController();

    apiClient
      .get<FetchResponse<T>>(endpoint, {
        signal: controller.signal,
      })
      .then((res) => {
        setData(res.data.results);
        setLoading(false);
      })
      .catch((err) => {
        if (err instanceof CanceledError) return;
        setLoading(false);
        setError(err);
      });

    return () => controller.abort();
  }, []);

  return { data, error, isLoading };
};

export default useData;

Here is the apiClient

import axios from "axios";

export default axios.create({
  baseURL: "https://api.themoviedb.org/3",
  params: {
    key: "ABC",
  },
  headers: {
    accept: "application/json",
    Authorization:
      "Bearer XYZ",
  },
});

this is where I use the useData hook in movies

import useData from "./UseData";

export interface Movie {
  id: number;
  title: string;
  backdrop_path: string;
  vote_average: number;
}

const useMovies = () => useData<Movie>("/discover/movie");

export default useMovies;

and this is where i use the useData hook in genres

import useData from "./UseData";

export interface Genre {
  id: number;
  name: string;
}

const useGenres = () => useData<Genre>("/genre/movie/list");

export default useGenres;

How can I create a generic data hook that can fetch data from both the endpoints without being specific to the attributes?

2

Answers


  1. It looks like the issue is that one of your API functions returns response object with the shape { results: Movie[]; total_results: number; } while the other returns a response object with the shape { genres: Genre[]; }.

    Instead of trying to generically type a FetchResponse object "payload" you could pass in the entire expected response interface for the API you are hitting.

    Example:

    import { useEffect, useState } from "react";
    import apiClient from "../services/api-client";
    import { CanceledError } from "axios";
    
    const useData = <FetchResponse>(endpoint: string) => {
      const [data, setData] = useState<FetchResponse | undefined>();
      const [error, setError] = useState<any>();
      const [isLoading, setLoading] = useState(false);
    
      useEffect(() => {
        setLoading(true);
        const controller = new AbortController();
    
        apiClient
          .get<FetchResponse>(endpoint, {
            signal: controller.signal,
          })
          .then((res) => {
            setData(res.data);
            setLoading(false);
          })
          .catch((err) => {
            setLoading(false); // clear loading before any early returns
    
            if (err instanceof CanceledError) return;
            setError(err);
          });
    
        return () => controller.abort();
      }, []);
    
      return { data, error, isLoading };
    };
    

    Example Usages:

    import useData from "./UseData";
    
    export interface Movie {
      id: number;
      title: string;
      backdrop_path: string;
      vote_average: number;
    }
    
    interface FetchMoviesResponse {
      total_results: number;
      results: Movie[];
    }
    
    const useMovies = () => useData<FetchMoviesResponse>("/discover/movie");
    
    export default useMovies;
    
    import useData from "./UseData";
    
    export interface Genre {
      id: number;
      name: string;
    }
    
    interface FetchGenreResponse {
      genres: Genre[];
    }
    
    const useGenres = () => useData<FetchGenreResponse>("/genre/movie/list");
    
    export default useGenres;
    
    Login or Signup to reply.
  2. The request config object of the Axios API accepts a response data transformation function at the property transformResponse. I’ll inline the documentation here:

    // `transformResponse` allows changes to the response data to be made before
    // it is passed to then/catch
    transformResponse: function (data) {
      // Do whatever you want to transform the data
    
      return data;
    },
    

    You can accept this function as the second parameter of your generic hook useData and then use it to select/pick/transform the response data for each of the more specific hooks: genres and movies. Here’s an example of what that might look like based on the code you provided:

    (Complete example at the TypeScript Playground)

    type UseDataResult<T> =
      | { // still loading
          data: undefined;
          error: undefined;
          isLoading: true;
        }
      | { // exception occurred
          data: undefined;
          error: unknown;
          isLoading: false;
        }
      | { // success: data received
          data: T;
          error: undefined;
          isLoading: false;
        };
    
    function useData<Initial, Final>(
      endpoint: string,
      transformResponse: (value: Initial) => Final,
    ): UseDataResult<Final> {
      const [data, setData] = useState<Final | undefined>();
      const [error, setError] = useState<unknown>();
      const [isLoading, setLoading] = useState(true);
      useEffect(() => {
        const controller = new AbortController();
        (async () => {
          try {
            setLoading(true);
            const axiosResponse = await apiClient.get(endpoint, {
              signal: controller.signal,
              transformResponse,
            });
            setData(axiosResponse.data);
            setError(undefined);
          } catch (cause) {
            setData(undefined);
            setError(cause);
          } finally {
            setLoading(false);
          }
        })();
        return () => controller.abort();
      }, []);
      return { data, error, isLoading } as UseDataResult<Final>;
    }
    
    const useMovies = () =>
      useData("/discover/movie", (data: { results: Movie[] }) => data.results);
    
    const useGenres = () =>
      useData("/genre/movie/list", (data: { genres: Genre[] }) => data.genres);
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search