Как создать универсальный крючок для извлечения данных в TypeScript для API с различными структурами ответов?

Я работаю над проектом поиска фильмов, где мне нужно получить данные из двух API (сайт фильма Tmdb) с разными структурами ответов (конечными точками фильмов и жанров). Чтобы избежать дублирования одной и той же логики для каждого перехватчика, который я использую для получения данных из API, я знаю, что могу использовать дженерики, но, к сожалению, когда я пытаюсь, я получаю неопределенную ошибку.

Текущие жанры

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;

Актуальные фильмы

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;

Но, как вы можете видеть, я продублировал ту же логику, что не является хорошей практикой.

Я попытался извлечь общий крючок для извлечения данных, чтобы уменьшить избыточность, но он работает только для конечной точки фильмов и возвращает неопределенное значение для жанров. Я считаю, что это связано с тем, что общий хук ожидает массив результатов, который присутствует в ответе фильмов, но не в ответе жанров. Вместо этого в ответе жанров используется массив жанров.

Моя попытка решения

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;

Вот APIКлиент

import axios from "axios";

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

здесь я использую хук useData в фильмах

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;

и здесь я использую хук useData в жанрах

import useData from "./UseData";

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

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

export default useGenres;

Как я могу создать универсальный перехватчик данных, который может получать данные из обеих конечных точек, не привязываясь к атрибутам?

Можете ли вы отредактировать , чтобы показать, где и как вы используете свой собственный useData крючок? Также было бы полезно рассказать, как выглядят эти ответы API, чтобы мы могли увидеть, что будет/должно вернуть использование. См. минимально воспроизводимый пример.

Drew Reese 21.06.2024 19:01

О, ок, думаю, я вижу проблему. Не возражаете ли вы также добавить свой код apiClient, который обрабатывает эти запросы и возвращает объекты ответа?

Drew Reese 21.06.2024 19:07

Огромное спасибо за внесенные вами исправления, я здесь еще новичок. Я включил запрошенный код.

Osteen Omega 21.06.2024 20:07
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Навигация по приложениям React: Исчерпывающее руководство по React Router
Навигация по приложениям React: Исчерпывающее руководство по React Router
React Router стала незаменимой библиотекой для создания одностраничных приложений с навигацией в React. В этой статье блога мы подробно рассмотрим...
Массив зависимостей в React
Массив зависимостей в React
Все о массиве Dependency и его связи с useEffect.
1
3
55
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Похоже, проблема в том, что одна из ваших функций API возвращает объект ответа с формой { results: Movie[]; total_results: number; }, а другая возвращает объект ответа с формой { genres: Genre[]; }.

Вместо того, чтобы пытаться в общих чертах ввести «полезную нагрузку» объекта FetchResponse, вы можете передать весь интерфейс ожидаемого ответа для API, к которому вы обращаетесь.

Пример:

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

Примеры использования:

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;

Огромное тебе спасибо, чувак, это сработало, и самое главное, я понял, где я ошибся. Я очень благодарен. Я застрял на два дня с этой проблемой.

Osteen Omega 21.06.2024 20:36

@OsteenOmega Не беспокойтесь, оказалось, что мне действительно не нужно было видеть ваш код Axios (кроме того, чтобы убедиться, что вы там не делаете ничего сумасшедшего), и на самом деле вы просто вводили ожидаемые объекты ответа. Здоровья и удачи!

Drew Reese 21.06.2024 20:39

Объект конфигурации запроса API Axios принимает функцию преобразования данных ответа в свойстве transformResponse. Я вставлю документацию сюда:

// `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;
},

Вы можете принять эту функцию в качестве второго параметра вашего общего перехватчика useData, а затем использовать ее для выбора/выбора/преобразования данных ответа для каждого из более конкретных перехватчиков: жанров и фильмов. Вот пример того, как это может выглядеть на основе предоставленного вами кода:

(Полный пример на 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);

Другие вопросы по теме