Select и трансформация данных

Опция select в TanStack Query предназначена для трансформации данных сразу после получения результата запроса и до передачи данных в компонент. Это один из важнейших механизмов библиотеки, позволяющий:

  • изменять структуру ответа API;
  • извлекать только нужные поля;
  • выполнять сортировку, фильтрацию и агрегацию;
  • уменьшать количество вычислений внутри компонентов;
  • централизовать преобразование данных;
  • скрывать особенности серверного API от UI-слоя.

Без select компоненты часто превращаются в место постоянной обработки данных:

const users = data?.users
  ?.filter(user => user.active)
  ?.sort((a, b) => a.name.localeCompare(b.name))

Такой подход приводит к ряду проблем:

  • логика размазывается по компонентам;
  • появляются дублирующиеся преобразования;
  • компонент становится перегруженным;
  • вычисления выполняются при каждом рендере.

select переносит эту логику непосредственно в слой запросов.


Базовый синтаксис

const query = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (data) => {
    return data.users
  }
})

Порядок работы:

  1. Выполняется queryFn
  2. Получаются сырые данные
  3. Вызывается select
  4. Результат select становится data

Пример ответа сервера

Предположим, API возвращает:

{
  "status": "success",
  "meta": {
    "count": 150
  },
  "users": [
    {
      "id": 1,
      "name": "Alex"
    },
    {
      "id": 2,
      "name": "John"
    }
  ]
}

Без select:

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers
})

console.log(data.users)

С select:

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (data) => data.users
})

console.log(data)

Теперь data содержит уже массив пользователей.


Извлечение конкретных полей

Очень распространённый сценарий — получение только нужной части ответа.

const { data: products } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  select: (response) => response.items
})

Компонент больше не знает структуру API.

Это особенно важно при:

  • смене backend;
  • рефакторинге API;
  • миграции между сервисами;
  • использовании нескольких источников данных.

Переименование и нормализация структуры

API часто возвращают неудобные названия полей.

Например:

{
  "user_id": 1,
  "user_name": "Alex",
  "user_email": "alex@test.com"
}

Через select можно нормализовать структуру:

const { data } = useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  select: (user) => ({
    id: user.user_id,
    name: user.user_name,
    email: user.user_email
  })
})

Теперь все компоненты работают с единой структурой данных.


Фильтрация данных

select идеально подходит для фильтрации.

const { data } = useQuery({
  queryKey: ['tasks'],
  queryFn: fetchTasks,
  select: (tasks) => {
    return tasks.filter(task => task.completed)
  }
})

Компонент получает уже готовый список завершённых задач.


Сортировка

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => {
    return [...users].sort((a, b) => {
      return a.name.localeCompare(b.name)
    })
  }
})

Важно создавать копию массива:

[...users]

Потому что sort() мутирует массив.

Мутация кэшированных данных TanStack Query — серьёзная ошибка.


Комбинирование фильтрации и сортировки

const { data } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  select: (products) => {
    return products
      .filter(product => product.inStock)
      .sort((a, b) => a.price - b.price)
  }
})

Вычисляемые поля

select часто используется для создания derived data.

const { data } = useQuery({
  queryKey: ['orders'],
  queryFn: fetchOrders,
  select: (orders) => {
    return orders.map(order => ({
      ...order,
      total: order.price * order.quantity
    }))
  }
})

Форматирование данных

Форматирование дат

const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  select: (posts) => {
    return posts.map(post => ({
      ...post,
      formattedDate: new Date(post.createdAt)
        .toLocaleDateString()
    }))
  }
})

Агрегация данных

Подсчёт суммы

const { data } = useQuery({
  queryKey: ['cart'],
  queryFn: fetchCart,
  select: (items) => {
    return {
      items,
      total: items.reduce((sum, item) => {
        return sum + item.price * item.quantity
      }, 0)
    }
  }
})

Группировка данных

const { data } = useQuery({
  queryKey: ['employees'],
  queryFn: fetchEmployees,
  select: (employees) => {
    return employees.reduce((groups, employee) => {
      const department = employee.department

      if (!groups[department]) {
        groups[department] = []
      }

      groups[department].push(employee)

      return groups
    }, {})
  }
})

Преобразование массива в объект

Частая задача — создание словаря по id.

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => {
    return users.reduce((acc, user) => {
      acc[user.id] = user
      return acc
    }, {})
  }
})

Результат:

{
  1: { id: 1, name: 'Alex' },
  2: { id: 2, name: 'John' }
}

Это значительно ускоряет поиск:

data[userId]

вместо:

users.find(user => user.id === userId)

select и мемоизация

select вызывается не на каждый рендер компонента.

TanStack Query оптимизирует вызовы:

  • если данные не изменились — повторного select не будет;
  • если ссылка на данные осталась прежней — результат переиспользуется.

Это одно из важнейших преимуществ перед вычислениями прямо в компоненте.


Влияние на ререндеры

Без select:

const filteredUsers = data?.filter(user => user.active)

Каждый рендер создаёт новый массив.

Это приводит к:

  • лишним ререндерам;
  • изменению ссылок;
  • проблемам с memo;
  • лишним вычислениям.

С select:

select: (users) => users.filter(user => user.active)

вычисление выполняется внутри TanStack Query.


select и кэш

Важно понимать:

select не изменяет данные в кэше.

Кэш всегда хранит оригинальный результат queryFn.

Например:

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.filter(user => user.active)
})

В кэше останется полный список пользователей.

Фильтрация применяется только для конкретного observer.

Это крайне важная особенность архитектуры.


Разные select для одного queryKey

Один и тот же запрос может использоваться с разными трансформациями.

const activeUsersQuery = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.filter(user => user.active)
})

const adminUsersQuery = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.filter(user => user.role === 'admin')
})

Сетевой запрос будет один.

Но данные для компонентов будут разными.


select и производительность

Плохой пример

select: (data) => {
  return veryHeavyCalculation(data)
}

Если вычисления тяжёлые:

  • сортировка огромных массивов;
  • сложная агрегация;
  • криптография;
  • построение графов;

то select может стать bottleneck.


Вынос тяжёлых вычислений

Иногда лучше:

  • использовать memoization;
  • выполнять обработку на backend;
  • применять Web Workers;
  • хранить подготовленные данные.

select и ошибки

Ошибка внутри select переводит query в состояние error.

Пример:

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (data) => {
    return data.users.map(user => user.name)
  }
})

Если data.users отсутствует:

Cannot read properties of undefined

query станет ошибочной.


Защитное программирование

select: (data) => {
  return data?.users?.map(user => user.name) ?? []
}

Использование optional chaining

select: (data) => {
  return data?.items ?? []
}

select и TypeScript

TanStack Query отлично выводит типы после трансформации.

const query = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => {
    return users.map(user => user.name)
  }
})

Тип data автоматически станет:

string[]

Изменение типа результата

Без select:

User[]

С select:

number

Пример:

const query = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.length
})

select и infinite queries

select работает и с useInfiniteQuery.

Структура данных:

{
  pages: [],
  pageParams: []
}

Пример:

const query = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  select: (data) => {
    return {
      ...data,
      pages: data.pages.flat()
    }
  }
})

Flatten страниц infinite query

Очень распространённая практика:

select: (data) => ({
  ...data,
  pages: data.pages.flatMap(page => page.items)
})

select и custom hooks

Очень полезный подход — скрывать трансформацию внутри hooks.

function useActiveUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    select: (users) => {
      return users.filter(user => user.active)
    }
  })
}

Компонент получает уже подготовленные данные.


Инкапсуляция логики

Хороший custom hook:

  • скрывает структуру API;
  • скрывает трансформации;
  • предоставляет готовые данные;
  • упрощает UI.

Переиспользование select

Можно выносить преобразования в отдельные функции.

function selectActiveUsers(users) {
  return users.filter(user => user.active)
}
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: selectActiveUsers
})

Композиция трансформаций

function filterActive(users) {
  return users.filter(user => user.active)
}

function sortByName(users) {
  return [...users].sort((a, b) => {
    return a.name.localeCompare(b.name)
  })
}

function transformUsers(users) {
  return sortByName(filterActive(users))
}

Когда select особенно полезен

Backend возвращает избыточные данные

{
  "meta": {},
  "pagination": {},
  "debug": {},
  "items": []
}

Несколько компонентов используют одинаковое преобразование


Нужно скрыть API-структуру


Требуется derived state


Необходима оптимизация ререндеров


Когда select использовать не стоит

Для тяжёлых вычислений


Для побочных эффектов

Нельзя:

select: (data) => {
  localStorage.setItem('users', JSON.stringify(data))
  return data
}

select должен быть чистой функцией.


Для асинхронных операций

Нельзя:

select: async (data) => {
  return await something(data)
}

select должен возвращать результат синхронно.


Чистые функции

Правильный select:

  • не мутирует данные;
  • не вызывает side effects;
  • не изменяет глобальное состояние;
  • не делает HTTP-запросы;
  • не зависит от внешнего изменяемого состояния.

Мутация данных — критическая ошибка

Неправильно:

select: (users) => {
  users.sort((a, b) => a.age - b.age)
  return users
}

Потому что изменяется оригинальный массив.

Правильно:

select: (users) => {
  return [...users].sort((a, b) => a.age - b.age)
}

select как адаптер между backend и frontend

Очень часто frontend не должен знать:

  • названия серверных полей;
  • особенности REST;
  • структуру GraphQL;
  • формат legacy API.

select превращается в слой адаптации.


Пример полноценной адаптации

const useProducts = () => {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    select: (response) => {
      return response.data.map(product => ({
        id: product.product_id,
        title: product.product_name,
        price: Number(product.product_price),
        inStock: product.available === 1
      }))
    }
  })
}

UI работает с удобной и предсказуемой структурой.


select и архитектура приложения

При грамотном использовании:

  • компоненты становятся проще;
  • уменьшается количество бизнес-логики в UI;
  • данные централизуются;
  • API-слой становится независимым;
  • код легче тестировать;
  • уменьшается дублирование.

Именно поэтому select считается одним из ключевых инструментов TanStack Query для построения чистой архитектуры клиентских приложений.