Привет, на связи KOTELOV! Мы перевели эту статью, чтобы понять, как эффективно преобразовывать данные при работе с REST API и библиотекой react-query.

Давайте посмотрим правде в глаза: большинство из нас не используют GraphQL. А если кто-то использует, то ему крупно повезло, потому что получает уникальную возможность запрашивать данные в том формате, в котором ему хочется. 

Но если вы работаете с REST, вы довольствуетесь тем, что возвращает бэкэнд. Так где лучше всего преобразовывать данные при работе с react-query? Универсальный ответ в разработке ПО применим и здесь: «Это зависит от обстоятельств». 

Разберем три подхода к преобразованию данных, их плюсы и минусы.

0. На бэкенде

Мой любимый подход, но везет с ним не всегда. Если бэкэнд возвращает данные именно в той структуре, которая вам нужна, то делать ничего не нужно. Кажется, что этого практически не бывает, но при работе с публичными REST API в корпоративных приложениях такое случается. 

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

? Нельзя работать на фронтенде;

? Не всегда можно использовать.

1. В queryFn

queryFn – это функция, которую вы передаете useQuery. Она работает так: вы вернете Promise, а полученные данные попадут в кэш запросов. Но это не значит, что вы должны обязательно возвращать данные в той структуре, которую предоставляет бэкенд. Вы можете преобразовать их перед этим:

// queryFn-transformation
const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  const data: Todos = response.data
  return data.map((todo) => todo.name.toUpperCase())
}

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

На фронтенде вы можете работать с этими данными «как будто они пришли из бэкенда». Нигде в коде вы не будете работать с именами todo, которые не являются заглавными. У вас также не будет доступа к исходной структуре. Если вы посмотрите на react-query-devtools, вы увидите преобразованную структуру. Если вы посмотрите на данные полученные от сети, вы увидите оригинальную структуру. Это может сбить с толку, поэтому имейте это в виду.

Кроме того, react-query не может ничего оптимизировать. Каждый раз при выполнении выборки будет выполняться преобразование. Если это дорого, рассмотрите одну из других альтернатив. Некоторые компании также имеют общий слой api, который абстрагирует получение данных, поэтому у вас может не быть доступа к этому слою для выполнения преобразований.

? Очень «близко к бэкенду» с точки зрения совместного размещения;

? Преобразованная структура оказывается в кэше, поэтому у вас нет доступа к исходной структуре;

? Выполняется при каждой выборке;

? Нецелесообразно, если у вас есть общий слой api, который вы не можете свободно модифицировать.

2. В функции рендеринга

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

// render-transformation
const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  return response.data
}

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  return response.data
}

export const useTodosQuery = () => {
  const queryInfo = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
  return {
    ...queryInfo,
    data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  }
}

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

Будьте осторожны, чтобы определить ваши зависимости как можно более узко. data внутри queryInfo будут ссылочно стабильными, если только что-то действительно не изменилось (в этом случае вы захотите пересчитать ваше преобразование), но сам queryInfo не будет. Если вы добавите queryInfo в качестве зависимости, трансформация будет снова выполняться при каждом рендере:

// useMemo-dependencies
export const useTodosQuery = () => {
  const queryInfo = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })
  return {
    ...queryInfo,
    // не используйте useMemo
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo]
    ),
    // ✅ корректно запоминает с помощью queryInfo.data
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo.data]
    ),
  }
}

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

Обновление

Поскольку в React Query отслеживаемые запросы включены по умолчанию с версии 4, распространение ...queryInfo больше не рекомендуется, поскольку оно вызывает геттеры для всех свойств.

? Оптимизация через useMemo;

? Точная структура не может быть проверена в devtools;

? Более запутанный синтаксис;

? Данные могут быть потенциально неопределенными;

? Не рекомендуется использовать в отслеживаемых запросах.

3. Использование опции select

В версии 3 появились встроенные селекторы, которые также можно использовать для преобразования данных:

// select-transformation
export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => data.map((todo) => todo.name.toUpperCase()),
  })

Селекторы будут вызываться только в том случае, если data существуют, поэтому вам не нужно заботиться о undefined. Селекторы, подобные приведенному выше, также будут выполняться при каждом рендере, поскольку функциональная идентичность меняется (это встроенная функция).

Если ваше преобразование дорогостоящее, вы можете мемоизировать его либо с помощью useCallback, либо извлекая его в стабильную ссылку на функцию:

// select-memoizations
const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ✅ использует стабильную ссылку на функцию
    select: transformTodoNames,
  })

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ✅ запоминает с помощью useCallback
    select: React.useCallback(
      (data: Todos) => data.map((todo) => todo.name.toUpperCase()),
      []
    ),
  })

Кроме того, с помощью опции select можно подписаться только на часть данных. Именно это делает данный подход уникальным. Рассмотрим следующий пример:

// select-partial-subscriptions
export const useTodosQuery = (select) =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select,
  })

export const useTodosCount = () =>
  useTodosQuery((data) => data.length)

export const useTodo = (id) =>
  useTodosQuery((data) => data.find((todo) => todo.id === id))

Здесь мы создали API типа useSelector, передав пользовательский селектор в наш useTodosQuery. Пользовательские хуки по-прежнему работают как и раньше, поскольку select будет undefined, если вы не передадите его, поэтому будет возвращено все состояние.

Но если вы передаете селектор, то вы подписываетесь только на результат функции селектора. Это довольно эффективное средство, поскольку оно означает, что даже если мы обновим имя todo, наш компонент, который подписывается только на счетчик через useTodosCount, не будет пересматриваться. Счетчик не изменился, поэтому react-query может решить не сообщать этому наблюдателю об обновлении!

? Лучшие оптимизации;

? Позволяет делать частичные подписки;

? Структура может быть разной для каждого наблюдателя;

? Структурное разделение выполняется дважды.

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

Комментарии (3)


  1. slavcopost
    26.06.2024 09:41
    +1

    А мне крупно повезло что я не работаю с GraphQL. И кажеться уже никогда не буду работать.


  1. Zukomux
    26.06.2024 09:41

    Вставляю 5 копеек. Всё, кроме использования селекта, это нарушение архитектуры приложения. Данные должны преобразовываться в том же слое, где они были запрошены. Это не должен быть транспортный слой - он представляет нам только запрос на бек. Это не должен быть слой рендера - это не его задача трансформировать данные. А вот использование селекта это самый правильный путь. У нас есть точка входа для вызова и тут же точка выхода данных. Контроль осуществляется в единственном месте и не размазан по приложению - коллеги скажут вам спасибо, что не приходится искать места трансформации. Для лучшего контроля можно создать хук useTodoQuery и уже в него дополнительно передавать опции самой квери, конечно же опционально. Это даёт нам переиспольщуемый хук с возможностью контроля данных в месте использования.


  1. AngusMetall
    26.06.2024 09:41

    Кажется, что этого практически не бывает, но при работе с публичными REST API в корпоративных приложениях такое случается. 

    Вот как раз в публичных API такое и случается только случайно, уж простите за тавтологию. Потому что оно и для вас, и для Пети, и для соседнего отдела, и для интеграции с другой компанией. GraphQL собственно для того и нужен, что бы дать возможность более гибко получать то что нужно всем заинтересованным. А вот что бы пготовить и отдавать ДТО, которое точно ложиться на ваш фронт используются подходы вроде BfF.