Большинство разработчиков начинают с загрузки данных через 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>
}Query Key — это идентификатор запроса. TanStack Query кэширует данные по ключу:
// Разные ключи = разные записи в кэше:
useQuery({ queryKey: ['users'] }) // все пользователи
useQuery({ queryKey: ['users', userId] }) // конкретный пользователь
useQuery({ queryKey: ['users', userId, 'posts'] })// посты пользователя
// При изменении queryKey — автоматически новый запрос:
useQuery({ queryKey: ['users', currentUserId] }) // сменился userId → новый fetch// staleTime: 0 — данные немедленно устаревают (по умолчанию)
// При каждом mount компонента — фоновый refetch
// staleTime: 5 * 60 * 1000 — данные свежи 5 минут
// Повторный mount в течение 5 минут = данные из кэша, без запроса
// staleTime: Infinity — данные никогда не устаревают
// Подходит для статичных данных (список стран, константы)
useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // список стран не меняется
})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()Большинство разработчиков начинают с загрузки данных через 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>
}Query Key — это идентификатор запроса. TanStack Query кэширует данные по ключу:
// Разные ключи = разные записи в кэше:
useQuery({ queryKey: ['users'] }) // все пользователи
useQuery({ queryKey: ['users', userId] }) // конкретный пользователь
useQuery({ queryKey: ['users', userId, 'posts'] })// посты пользователя
// При изменении queryKey — автоматически новый запрос:
useQuery({ queryKey: ['users', currentUserId] }) // сменился userId → новый fetch// staleTime: 0 — данные немедленно устаревают (по умолчанию)
// При каждом mount компонента — фоновый refetch
// staleTime: 5 * 60 * 1000 — данные свежи 5 минут
// Повторный mount в течение 5 минут = данные из кэша, без запроса
// staleTime: Infinity — данные никогда не устаревают
// Подходит для статичных данных (список стран, константы)
useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // список стран не меняется
})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 при его изменении.