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

Преобразование данных

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

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

Зависит от обстоятельств.

— Каждый разработчик, всегда

Вот 3+1 подхода к преобразованию данных с их плюсами и минусами:

0. На бэкенде

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

? никакой работы над фронтендом

? не всегда возможно

1. В queryFn

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

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, то увидите преобразованную структуру. Но если вы посмотрите на панель network, вы увидите исходную структуру. Это может сбить с толку, так что имейте это в виду.

Кроме того, здесь нет никакой оптимизации, которую React-Query может сделать за вас. Каждый раз, когда выполняется запрос, будет выполняться ваше преобразование. Если это дорого, рассмотрите другие варианты. Некоторые компании также имеют слой API, который отделяет запросы данных, и у вас может не быть доступа к этому уровню.

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

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

? запускается на каждом fetch

? невозможно, если у вас есть уровень работы с API, который вы не можете свободно изменять.

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

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

  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()),
  }
}

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

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

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]
    ),
  }
}

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

Уточнение

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

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

3. C помощью опции select

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

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

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

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 можно использовать для подписки только на части данных. Именно это делает данный подход поистине уникальным. Рассмотрим следующий пример:

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 может не сообщать этой функции об обновлении ?

? лучшие оптимизации
? позволяет подписываться на части состояния
? структура может быть разной для каждой функции-наблюдателя
? структурное разделение кода выполняется дважды


Это всё, я буду ещё переводить про React-query так что если хотите подписывайтесь.

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


  1. noodles
    26.04.2024 12:23

    Ну так как же всё-таки с помощью react-query сделать несколько запросов с условиями внутри jsx-компонента?..) Например, как это переписать на react-query? Желательно красиво и в продакшн-реди стиле.. хочу посмотреть что выйдет.)

    const MyComponent = () => {
        
        // - по сабмиту получаем данные из формы
    
        // - делаем три параллельных запроса, в каждом 
        // из которых свои данные формы
    
        // - по результату второго запроса - делаем или не делаем 
        // четвёртый запрос, в котором используем ответ первого запроса.
    
        // - если четвёртый запрос ок - показываем успех 
        // с данными из этого запроса-4
    
        const submitHandler = async (formData) => {
          const responses = await Promise.all([
            service1.fetchData(formData.fieldValue1),
            service2.fetchData(formData.fieldValue2),
            service3.fetchData(formData.fieldValue3),
          ]);
        
          if (responses[1].isExistSomeData) {
            const data = await service4.fetchData(responses[0]);
            service5.showSuccess(data);  
          } else {
            service5.showFailure();  
          }
        };
    
        return <SomeButton onClick={submitHandler}>click</SomeButton>
    };