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