reactjstypescripttypescript-genericsreact-custom-hooks

How to Create a Generic Data Fetching Hook in TypeScript for APIs with Different Response Structures?


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?


Solution

  • 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;