← React/TanStack Query: умный data fetching#286 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

TanStack Query: умный data fetching

Проблемы useEffect для загрузки данных

Большинство разработчиков начинают с загрузки данных через useEffect + useState. Это работает, но имеет серьёзные проблемы:

// Типичный "наивный" подход с множеством проблем:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setIsLoading(true)
    fetch('/api/users/' + userId)
      .then(r => r.json())
      .then(data => {
        setUser(data)      // ПРОБЛЕМА 1: гонка условий (race condition)
        setIsLoading(false)
      })
      .catch(err => {
        setError(err)
        setIsLoading(false)
      })
  }, [userId])
  // ПРОБЛЕМА 2: нет кэширования — каждый рендер = новый запрос
  // ПРОБЛЕМА 3: нет дедупликации — 2 компонента = 2 запроса
  // ПРОБЛЕМА 4: нет фоновых обновлений
  // ПРОБЛЕМА 5: нет retry при ошибке
}

TanStack Query (бывший React Query) решает все эти проблемы из коробки.

Основные концепции

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// useQuery: загрузка данных с кэшированием
function UserProfile({ userId }) {
  const { data: user, isLoading, isError, error } = useQuery({
    queryKey: ['user', userId],  // ключ для кэша (массив)
    queryFn: () => fetchUser(userId),  // функция загрузки
    staleTime: 5 * 60 * 1000,  // данные "свежие" 5 минут
    retry: 3,  // 3 повтора при ошибке
  })

  if (isLoading) return <Spinner />
  if (isError)   return <Error message={error.message} />
  return <div>{user.name}</div>
}

queryKey: умное кэширование

Query Key — это идентификатор запроса. TanStack Query кэширует данные по ключу:

// Разные ключи = разные записи в кэше:
useQuery({ queryKey: ['users'] })                // все пользователи
useQuery({ queryKey: ['users', userId] })         // конкретный пользователь
useQuery({ queryKey: ['users', userId, 'posts'] })// посты пользователя

// При изменении queryKey — автоматически новый запрос:
useQuery({ queryKey: ['users', currentUserId] })  // сменился userId → новый fetch

staleTime: стратегия свежести данных

// staleTime: 0 — данные немедленно устаревают (по умолчанию)
// При каждом mount компонента — фоновый refetch

// staleTime: 5 * 60 * 1000 — данные свежи 5 минут
// Повторный mount в течение 5 минут = данные из кэша, без запроса

// staleTime: Infinity — данные никогда не устаревают
// Подходит для статичных данных (список стран, константы)

useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: Infinity,  // список стран не меняется
})

useMutation: изменение данных

const queryClient = useQueryClient()

const updateUser = useMutation({
  mutationFn: (updates) => api.updateUser(userId, updates),

  // Оптимистичное обновление: меняем кэш ДО ответа сервера
  onMutate: async (updates) => {
    await queryClient.cancelQueries({ queryKey: ['user', userId] })

    const previousUser = queryClient.getQueryData(['user', userId])
    queryClient.setQueryData(['user', userId], old => ({ ...old, ...updates }))

    return { previousUser }  // для возможного отката
  },

  // Откат при ошибке:
  onError: (err, updates, context) => {
    queryClient.setQueryData(['user', userId], context.previousUser)
  },

  // Инвалидация после успешного сохранения:
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

// Использование:
<button onClick={() => updateUser.mutate({ name: 'Новое имя' })}>
  {updateUser.isPending ? 'Сохраняем...' : 'Сохранить'}
</button>

Инвалидация запросов

// Инвалидировать = пометить как устаревшие = при следующем mount сделать refetch

// Точечная инвалидация:
queryClient.invalidateQueries({ queryKey: ['user', userId] })

// Инвалидация по частичному ключу (все запросы 'user'):
queryClient.invalidateQueries({ queryKey: ['user'] })

// Немедленный refetch в фоне:
queryClient.refetchQueries({ queryKey: ['posts'] })

Фоновое обновление данных

useQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  // Рефетч при фокусировке окна браузера:
  refetchOnWindowFocus: true,
  // Рефетч каждые 30 секунд:
  refetchInterval: 30 * 1000,
})

Бесконечный скролл

import { useInfiniteQuery } from '@tanstack/react-query'

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
})

// data.pages — массив страниц
// fetchNextPage() — загрузить следующую страницу

Примеры

Реализация кэша запросов с нуля: дедупликация in-flight запросов, staleTime, фоновое обновление, инвалидация и оптимистичные обновления

// Строим упрощённый аналог TanStack Query на чистом JavaScript.
// Понимание внутренней работы помогает правильно использовать библиотеку.

// --- QueryCache ---

class QueryCache {
  constructor() {
    this.cache = new Map()     // queryKey -> CacheEntry
    this.observers = new Map() // queryKey -> Set<callback>
  }

  // Формируем строковый ключ из массива
  getKey(queryKey) {
    return JSON.stringify(queryKey)
  }

  get(queryKey) {
    return this.cache.get(this.getKey(queryKey))
  }

  set(queryKey, entry) {
    const key = this.getKey(queryKey)
    this.cache.set(key, entry)
    this.notify(key)
  }

  invalidate(queryKey) {
    const key = this.getKey(queryKey)
    const entry = this.cache.get(key)
    if (entry) {
      entry.isStale = true
      this.notify(key)
    }
  }

  subscribe(queryKey, callback) {
    const key = this.getKey(queryKey)
    if (!this.observers.has(key)) this.observers.set(key, new Set())
    this.observers.get(key).add(callback)
    return () => this.observers.get(key)?.delete(callback)
  }

  notify(key) {
    this.observers.get(key)?.forEach(fn => fn())
  }
}

// --- QueryClient ---

class QueryClient {
  constructor() {
    this.cache = new QueryCache()
    this.inFlight = new Map()  // queryKey -> Promise (дедупликация!)
  }

  async fetchQuery({ queryKey, queryFn, staleTime = 0 }) {
    const key = JSON.stringify(queryKey)
    const cached = this.cache.get(queryKey)

    // 1. Данные свежие — возвращаем из кэша без запроса
    if (cached && !cached.isStale) {
      const age = Date.now() - cached.fetchedAt
      if (age < staleTime) {
        console.log('Cache HIT [fresh]:', key, '(возраст: ' + age + 'мс)')
        return cached.data
      }
    }

    // 2. Запрос уже выполняется — дедупликация, ждём тот же промис
    if (this.inFlight.has(key)) {
      console.log('Dedup [in-flight]:', key, '— ожидаем существующий запрос')
      return this.inFlight.get(key)
    }

    // 3. Новый запрос
    console.log('Cache MISS:', key, '— отправляем запрос...')

    const promise = queryFn()
      .then(data => {
        this.cache.set(queryKey, {
          data,
          fetchedAt: Date.now(),
          isStale: false,
          error: null,
        })
        this.inFlight.delete(key)
        return data
      })
      .catch(error => {
        this.cache.set(queryKey, {
          data: null,
          fetchedAt: Date.now(),
          isStale: true,
          error: error.message,
        })
        this.inFlight.delete(key)
        throw error
      })

    this.inFlight.set(key, promise)
    return promise
  }

  setQueryData(queryKey, updater) {
    const cached = this.cache.get(queryKey)
    const currentData = cached?.data || null
    const newData = typeof updater === 'function' ? updater(currentData) : updater

    this.cache.set(queryKey, {
      data: newData,
      fetchedAt: Date.now(),
      isStale: false,
      error: null,
    })
  }

  invalidateQueries(queryKey) {
    this.cache.invalidate(queryKey)
    console.log('Invalidated:', JSON.stringify(queryKey))
  }

  getQueryData(queryKey) {
    return this.cache.get(queryKey)?.data || null
  }
}

// --- Fake API ---

let requestCount = 0
const fakeDB = {
  users: {
    1: { id: 1, name: 'Алексей', email: 'alex@example.com' },
    2: { id: 2, name: 'Мария', email: 'maria@example.com' },
  }
}

function fetchUser(id) {
  requestCount++
  const reqNum = requestCount
  console.log('  [API] Запрос #' + reqNum + ' для userId=' + id)
  return new Promise(resolve =>
    setTimeout(() => {
      console.log('  [API] Ответ #' + reqNum)
      resolve(fakeDB.users[id])
    }, 50)
  )
}

// --- Тесты ---

async function runTests() {
  const client = new QueryClient()

  console.log('=== Тест 1: Базовый fetch и кэш ===')
  const user1a = await client.fetchQuery({
    queryKey: ['user', 1],
    queryFn: () => fetchUser(1),
    staleTime: 5000,
  })
  console.log('Результат:', user1a.name)

  // Повторный запрос — должен вернуть из кэша
  const user1b = await client.fetchQuery({
    queryKey: ['user', 1],
    queryFn: () => fetchUser(1),
    staleTime: 5000,
  })
  console.log('Из кэша:', user1b.name)
  console.log('HTTP запросов всего:', requestCount, '(должно быть 1)')

  console.log('
=== Тест 2: Дедупликация ===')
  requestCount = 0

  // Параллельные запросы к одному ресурсу
  const [u1, u2, u3] = await Promise.all([
    client.fetchQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) }),
    client.fetchQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) }),
    client.fetchQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) }),
  ])
  console.log('3 параллельных запроса, HTTP запросов:', requestCount, '(должно быть 1)')
  console.log('Все результаты одинаковые:', u1.name === u2.name && u2.name === u3.name)

  console.log('
=== Тест 3: Оптимистичное обновление ===')
  const userId = 1

  // Сохраняем старые данные
  const old = client.getQueryData(['user', userId])
  console.log('До обновления:', old?.name)

  // Оптимистично меняем кэш (до ответа сервера)
  client.setQueryData(['user', userId], old => ({ ...old, name: 'Алексей Обновлённый' }))
  console.log('Оптимистично:', client.getQueryData(['user', userId])?.name)

  console.log('
=== Тест 4: Инвалидация ===')
  client.invalidateQueries(['user', 1])
  const afterInvalidate = client.cache.get(['user', 1])
  console.log('После инвалидации isStale:', afterInvalidate?.isStale)  // true
}

runTests()

TanStack Query: умный data fetching

Проблемы useEffect для загрузки данных

Большинство разработчиков начинают с загрузки данных через useEffect + useState. Это работает, но имеет серьёзные проблемы:

// Типичный "наивный" подход с множеством проблем:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setIsLoading(true)
    fetch('/api/users/' + userId)
      .then(r => r.json())
      .then(data => {
        setUser(data)      // ПРОБЛЕМА 1: гонка условий (race condition)
        setIsLoading(false)
      })
      .catch(err => {
        setError(err)
        setIsLoading(false)
      })
  }, [userId])
  // ПРОБЛЕМА 2: нет кэширования — каждый рендер = новый запрос
  // ПРОБЛЕМА 3: нет дедупликации — 2 компонента = 2 запроса
  // ПРОБЛЕМА 4: нет фоновых обновлений
  // ПРОБЛЕМА 5: нет retry при ошибке
}

TanStack Query (бывший React Query) решает все эти проблемы из коробки.

Основные концепции

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// useQuery: загрузка данных с кэшированием
function UserProfile({ userId }) {
  const { data: user, isLoading, isError, error } = useQuery({
    queryKey: ['user', userId],  // ключ для кэша (массив)
    queryFn: () => fetchUser(userId),  // функция загрузки
    staleTime: 5 * 60 * 1000,  // данные "свежие" 5 минут
    retry: 3,  // 3 повтора при ошибке
  })

  if (isLoading) return <Spinner />
  if (isError)   return <Error message={error.message} />
  return <div>{user.name}</div>
}

queryKey: умное кэширование

Query Key — это идентификатор запроса. TanStack Query кэширует данные по ключу:

// Разные ключи = разные записи в кэше:
useQuery({ queryKey: ['users'] })                // все пользователи
useQuery({ queryKey: ['users', userId] })         // конкретный пользователь
useQuery({ queryKey: ['users', userId, 'posts'] })// посты пользователя

// При изменении queryKey — автоматически новый запрос:
useQuery({ queryKey: ['users', currentUserId] })  // сменился userId → новый fetch

staleTime: стратегия свежести данных

// staleTime: 0 — данные немедленно устаревают (по умолчанию)
// При каждом mount компонента — фоновый refetch

// staleTime: 5 * 60 * 1000 — данные свежи 5 минут
// Повторный mount в течение 5 минут = данные из кэша, без запроса

// staleTime: Infinity — данные никогда не устаревают
// Подходит для статичных данных (список стран, константы)

useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: Infinity,  // список стран не меняется
})

useMutation: изменение данных

const queryClient = useQueryClient()

const updateUser = useMutation({
  mutationFn: (updates) => api.updateUser(userId, updates),

  // Оптимистичное обновление: меняем кэш ДО ответа сервера
  onMutate: async (updates) => {
    await queryClient.cancelQueries({ queryKey: ['user', userId] })

    const previousUser = queryClient.getQueryData(['user', userId])
    queryClient.setQueryData(['user', userId], old => ({ ...old, ...updates }))

    return { previousUser }  // для возможного отката
  },

  // Откат при ошибке:
  onError: (err, updates, context) => {
    queryClient.setQueryData(['user', userId], context.previousUser)
  },

  // Инвалидация после успешного сохранения:
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

// Использование:
<button onClick={() => updateUser.mutate({ name: 'Новое имя' })}>
  {updateUser.isPending ? 'Сохраняем...' : 'Сохранить'}
</button>

Инвалидация запросов

// Инвалидировать = пометить как устаревшие = при следующем mount сделать refetch

// Точечная инвалидация:
queryClient.invalidateQueries({ queryKey: ['user', userId] })

// Инвалидация по частичному ключу (все запросы 'user'):
queryClient.invalidateQueries({ queryKey: ['user'] })

// Немедленный refetch в фоне:
queryClient.refetchQueries({ queryKey: ['posts'] })

Фоновое обновление данных

useQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  // Рефетч при фокусировке окна браузера:
  refetchOnWindowFocus: true,
  // Рефетч каждые 30 секунд:
  refetchInterval: 30 * 1000,
})

Бесконечный скролл

import { useInfiniteQuery } from '@tanstack/react-query'

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
})

// data.pages — массив страниц
// fetchNextPage() — загрузить следующую страницу

Примеры

Реализация кэша запросов с нуля: дедупликация in-flight запросов, staleTime, фоновое обновление, инвалидация и оптимистичные обновления

// Строим упрощённый аналог TanStack Query на чистом JavaScript.
// Понимание внутренней работы помогает правильно использовать библиотеку.

// --- QueryCache ---

class QueryCache {
  constructor() {
    this.cache = new Map()     // queryKey -> CacheEntry
    this.observers = new Map() // queryKey -> Set<callback>
  }

  // Формируем строковый ключ из массива
  getKey(queryKey) {
    return JSON.stringify(queryKey)
  }

  get(queryKey) {
    return this.cache.get(this.getKey(queryKey))
  }

  set(queryKey, entry) {
    const key = this.getKey(queryKey)
    this.cache.set(key, entry)
    this.notify(key)
  }

  invalidate(queryKey) {
    const key = this.getKey(queryKey)
    const entry = this.cache.get(key)
    if (entry) {
      entry.isStale = true
      this.notify(key)
    }
  }

  subscribe(queryKey, callback) {
    const key = this.getKey(queryKey)
    if (!this.observers.has(key)) this.observers.set(key, new Set())
    this.observers.get(key).add(callback)
    return () => this.observers.get(key)?.delete(callback)
  }

  notify(key) {
    this.observers.get(key)?.forEach(fn => fn())
  }
}

// --- QueryClient ---

class QueryClient {
  constructor() {
    this.cache = new QueryCache()
    this.inFlight = new Map()  // queryKey -> Promise (дедупликация!)
  }

  async fetchQuery({ queryKey, queryFn, staleTime = 0 }) {
    const key = JSON.stringify(queryKey)
    const cached = this.cache.get(queryKey)

    // 1. Данные свежие — возвращаем из кэша без запроса
    if (cached && !cached.isStale) {
      const age = Date.now() - cached.fetchedAt
      if (age < staleTime) {
        console.log('Cache HIT [fresh]:', key, '(возраст: ' + age + 'мс)')
        return cached.data
      }
    }

    // 2. Запрос уже выполняется — дедупликация, ждём тот же промис
    if (this.inFlight.has(key)) {
      console.log('Dedup [in-flight]:', key, '— ожидаем существующий запрос')
      return this.inFlight.get(key)
    }

    // 3. Новый запрос
    console.log('Cache MISS:', key, '— отправляем запрос...')

    const promise = queryFn()
      .then(data => {
        this.cache.set(queryKey, {
          data,
          fetchedAt: Date.now(),
          isStale: false,
          error: null,
        })
        this.inFlight.delete(key)
        return data
      })
      .catch(error => {
        this.cache.set(queryKey, {
          data: null,
          fetchedAt: Date.now(),
          isStale: true,
          error: error.message,
        })
        this.inFlight.delete(key)
        throw error
      })

    this.inFlight.set(key, promise)
    return promise
  }

  setQueryData(queryKey, updater) {
    const cached = this.cache.get(queryKey)
    const currentData = cached?.data || null
    const newData = typeof updater === 'function' ? updater(currentData) : updater

    this.cache.set(queryKey, {
      data: newData,
      fetchedAt: Date.now(),
      isStale: false,
      error: null,
    })
  }

  invalidateQueries(queryKey) {
    this.cache.invalidate(queryKey)
    console.log('Invalidated:', JSON.stringify(queryKey))
  }

  getQueryData(queryKey) {
    return this.cache.get(queryKey)?.data || null
  }
}

// --- Fake API ---

let requestCount = 0
const fakeDB = {
  users: {
    1: { id: 1, name: 'Алексей', email: 'alex@example.com' },
    2: { id: 2, name: 'Мария', email: 'maria@example.com' },
  }
}

function fetchUser(id) {
  requestCount++
  const reqNum = requestCount
  console.log('  [API] Запрос #' + reqNum + ' для userId=' + id)
  return new Promise(resolve =>
    setTimeout(() => {
      console.log('  [API] Ответ #' + reqNum)
      resolve(fakeDB.users[id])
    }, 50)
  )
}

// --- Тесты ---

async function runTests() {
  const client = new QueryClient()

  console.log('=== Тест 1: Базовый fetch и кэш ===')
  const user1a = await client.fetchQuery({
    queryKey: ['user', 1],
    queryFn: () => fetchUser(1),
    staleTime: 5000,
  })
  console.log('Результат:', user1a.name)

  // Повторный запрос — должен вернуть из кэша
  const user1b = await client.fetchQuery({
    queryKey: ['user', 1],
    queryFn: () => fetchUser(1),
    staleTime: 5000,
  })
  console.log('Из кэша:', user1b.name)
  console.log('HTTP запросов всего:', requestCount, '(должно быть 1)')

  console.log('
=== Тест 2: Дедупликация ===')
  requestCount = 0

  // Параллельные запросы к одному ресурсу
  const [u1, u2, u3] = await Promise.all([
    client.fetchQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) }),
    client.fetchQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) }),
    client.fetchQuery({ queryKey: ['user', 2], queryFn: () => fetchUser(2) }),
  ])
  console.log('3 параллельных запроса, HTTP запросов:', requestCount, '(должно быть 1)')
  console.log('Все результаты одинаковые:', u1.name === u2.name && u2.name === u3.name)

  console.log('
=== Тест 3: Оптимистичное обновление ===')
  const userId = 1

  // Сохраняем старые данные
  const old = client.getQueryData(['user', userId])
  console.log('До обновления:', old?.name)

  // Оптимистично меняем кэш (до ответа сервера)
  client.setQueryData(['user', userId], old => ({ ...old, name: 'Алексей Обновлённый' }))
  console.log('Оптимистично:', client.getQueryData(['user', userId])?.name)

  console.log('
=== Тест 4: Инвалидация ===')
  client.invalidateQueries(['user', 1])
  const afterInvalidate = client.cache.get(['user', 1])
  console.log('После инвалидации isStale:', afterInvalidate?.isStale)  // true
}

runTests()

Задание

Создай кастомный хук `useDataFetcher(url)`, который демонстрирует паттерн загрузки данных как в TanStack Query. Хук должен возвращать `{ data, isLoading, error, refetch }`. Используй `useState` для состояний и `useEffect` для выполнения запроса. При изменении URL данные перезагружаются. Функция `refetch` принудительно обновляет данные. Заполни пропуски `???`.

Подсказка

useState для data, isLoading, error. useCallback для fetchData (мемоизация функции с зависимостью от url). useEffect для вызова fetchData при его изменении.

Загружаем среду выполнения...
Загружаем AI-помощника...