TanStack Query ориентирован на модель получения данных по запросу, однако современные приложения часто работают с событиями в реальном времени. Сервер может отправлять обновления через WebSocket, Server-Sent Events, MQTT, GraphQL Subscriptions или собственный транспорт событий. В таких системах кеш необходимо обновлять без повторного запроса.
События позволяют:
Типичные примеры:
TanStack Query предоставляет несколько механизмов обновления кеша:
queryClient.setQueryDataqueryClient.setQueriesDataqueryClient.invalidateQueriesqueryClient.refetchQueriesqueryClient.removeQueriesГлавным инструментом при обработке событий обычно становится
setQueryData.
Наиболее распространённая схема выглядит следующим образом:
useQuery.const query = useQuery({
queryKey: ['messages'],
queryFn: fetchMessages
})
После получения события:
queryClient.setQueryData(['messages'], updater)
TanStack Query самостоятельно уведомляет все компоненты, использующие этот ключ.
Простейший вариант подключения:
const socket = new WebSocket('wss://example.com/ws')
Обработка событий:
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data)
console.log(data)
})
Интеграция с TanStack Query:
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
queryClient.setQueryData(['messages'], (old) => {
if (!old) {
return [message]
}
return [...old, message]
})
})
После получения нового сообщения список обновится без refetch.
Наиболее частая задача — добавление элемента в массив.
Исходный кеш:
[
{ id: 1, text: 'Hello' },
{ id: 2, text: 'World' }
]
Событие:
{
id: 3,
text: 'New message'
}
Обновление:
queryClient.setQueryData(['messages'], (old = []) => {
return [...old, newMessage]
})
Важно сохранять иммутабельность. Нельзя мутировать старый массив:
old.push(newMessage)
return old
Такой код может нарушить механизм отслеживания изменений.
Сервер может отправлять изменение существующего объекта.
Пример события:
{
id: 5,
status: 'completed'
}
Обновление кеша:
queryClient.setQueryData(['tasks'], (old = []) => {
return old.map((task) => {
if (task.id !== updatedTask.id) {
return task
}
return {
...task,
...updatedTask
}
})
})
Подобная схема используется:
Пример удаления записи:
queryClient.setQueryData(['tasks'], (old = []) => {
return old.filter((task) => task.id !== deletedTaskId)
})
Такой подход избавляет от необходимости повторно загружать весь список.
Часто данные хранятся не массивом, а объектом.
Пример:
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile
})
Обновление:
queryClient.setQueryData(['profile'], (old) => {
return {
...old,
online: true,
lastSeen: null
}
})
Один объект может присутствовать сразу в нескольких запросах:
Пример:
['users']
['user', 15]
['search', 'john']
Событие изменения пользователя:
{
id: 15,
name: 'John Smith'
}
Обновление нескольких кешей:
queryClient.setQueryData(['user', 15], (old) => {
return {
...old,
...userUpdate
}
})
queryClient.setQueryData(['users'], (old = []) => {
return old.map((user) => {
if (user.id !== 15) {
return user
}
return {
...user,
...userUpdate
}
})
})
Когда требуется обновить группу запросов:
queryClient.setQueriesData(
{ queryKey: ['users'] },
(old) => {
return old
}
)
Метод проходит по всем совпадающим ключам.
Пример:
queryClient.setQueriesData(
{ queryKey: ['messages'] },
(old = []) => {
return old.map((message) => {
if (message.id !== incoming.id) {
return message
}
return {
...message,
...incoming
}
})
}
)
Серверные события часто приходят в разных форматах.
Пример:
{
type: 'task.updated',
payload: {
id: 10,
status: 'done'
}
}
Полезно создавать единый диспетчер событий:
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'task.updated':
handleTaskUpdated(message.payload)
break
case 'task.deleted':
handleTaskDeleted(message.payload)
break
}
})
Обработчики лучше выносить в отдельные функции.
Плохой вариант:
socket.addEventListener('message', (event) => {
// 300 строк обновлений
})
Хороший вариант:
function updateTask(task) {
queryClient.setQueryData(['tasks'], (old = []) => {
return old.map((item) => {
if (item.id !== task.id) {
return item
}
return {
...item,
...task
}
})
})
}
При infinite query структура кеша отличается.
Пример:
{
pages: [
[...],
[...],
[...]
],
pageParams: [...]
}
Обновление:
queryClient.setQueryData(['feed'], (old) => {
if (!old) {
return old
}
return {
...old,
pages: old.pages.map((page) => {
return page.map((post) => {
if (post.id !== updatedPost.id) {
return post
}
return {
...post,
...updatedPost
}
})
})
}
})
Пример вставки нового элемента в первую страницу:
queryClient.setQueryData(['feed'], (old) => {
if (!old) {
return old
}
return {
...old,
pages: [
[newPost, ...old.pages[0]],
...old.pages.slice(1)
]
}
})
Сервер может отправлять повторяющиеся события.
Например:
{
id: 15,
type: 'message.created'
}
Защита от дублей:
queryClient.setQueryData(['messages'], (old = []) => {
const exists = old.some((item) => item.id === message.id)
if (exists) {
return old
}
return [...old, message]
})
Иногда событие содержит недостаточно информации для локального обновления.
Пример:
{
type: 'report.generated'
}
В таком случае лучше выполнить инвалидирование:
queryClient.invalidateQueries({
queryKey: ['reports']
})
После этого TanStack Query выполнит refetch.
Преимущества:
Недостатки:
Преимущества:
Недостатки:
В реальных приложениях оба подхода комбинируются.
После потери соединения часть событий может быть пропущена.
Типичная стратегия:
Пример:
socket.addEventListener('open', () => {
queryClient.invalidateQueries({
queryKey: ['messages']
})
})
События могут приходить не по порядку.
Пример проблемы:
Без проверки более старое событие затрёт новое состояние.
Решение:
queryClient.setQueryData(['task', task.id], (old) => {
if (!old) {
return task
}
if (task.version < old.version) {
return old
}
return {
...old,
...task
}
})
Вместо версии часто применяют временные метки.
if (incoming.updatedAt < old.updatedAt) {
return old
}
При большом количестве событий могут возникать проблемы:
Распространённые техники оптимизации:
notifyManager.batch(() => {
queryClient.setQueryData(...)
queryClient.setQueryData(...)
queryClient.setQueryData(...)
})
const throttledUpdate = throttle(updateCache, 100)
const queue = []
Далее события обрабатываются пакетами.
Если приложение открыто в нескольких вкладках, события могут приходить только в одну из них.
TanStack Query поддерживает синхронизацию через BroadcastChannel.
Пример:
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental'
Настройка:
broadcastQueryClient({
queryClient,
broadcastChannel: 'app'
})
Теперь обновление кеша будет распространяться между вкладками.
Пример подключения:
const source = new EventSource('/events')
Обработка:
source.addEventListener('message', (event) => {
const data = JSON.parse(event.data)
queryClient.setQueryData(['notifications'], (old = []) => {
return [data, ...old]
})
})
Пример через Apollo:
subscription.onMessage((event) => {
queryClient.setQueryData(['chat'], (old = []) => {
return [...old, event.message]
})
})
TanStack Query не зависит от конкретного транспорта.
WebSocket обычно создаётся:
Пример custom hook:
function useSocket() {
useEffect(() => {
const socket = new WebSocket('wss://example.com')
return () => {
socket.close()
}
}, [])
}
Иногда удобно создавать hook:
function useMessageEvents() {
const queryClient = useQueryClient()
useEffect(() => {
const socket = new WebSocket('wss://example.com')
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
queryClient.setQueryData(['messages'], (old = []) => {
return [...old, message]
})
})
return () => {
socket.close()
}
}, [queryClient])
}
В крупных приложениях используется промежуточный слой:
eventBus.on('task.updated', updateTask)
eventBus.on('message.created', updateMessage)
Преимущества:
Частая проблема — конфликт optimistic update и серверного события.
Сценарий:
Для решения используют:
Пример:
if (incoming.txId === optimisticTxId) {
return old
}
При потере WebSocket:
socket.addEventListener('close', () => {
console.log('Disconnected')
})
Типичные стратегии:
Пример:
const timeout = Math.min(1000 * 2 ** attempts, 30000)
Это предотвращает перегрузку сервера при массовом переподключении клиентов.
Перед обновлением важно учитывать отсутствие данных.
Плохой вариант:
old.map(...)
Безопасный вариант:
if (!old) {
return old
}
Проблемный код:
old.user.profile.name = 'John'
Корректное обновление:
return {
...old,
user: {
...old.user,
profile: {
...old.user.profile,
name: 'John'
}
}
}
События в реальном времени не гарантируют абсолютную синхронность.
Поэтому приложения часто комбинируют:
Это снижает вероятность накопления ошибок состояния.
События проще обрабатывать при предсказуемой структуре query keys.
Хороший вариант:
['tasks']
['tasks', 'list']
['tasks', 'detail', id]
Плохой вариант:
['list']
['data']
['info']
Структурированные ключи позволяют проще находить и обновлять связанные кеши.
Иногда событие означает изменение большого объёма данных.
Пример:
{
type: 'permissions.changed'
}
Вместо множества setQueryData эффективнее:
queryClient.invalidateQueries()
Либо:
queryClient.clear()
если требуется полный сброс состояния.
Незакрытые соединения приводят к:
Обязательно:
return () => {
socket.close()
}
Также необходимо удалять listeners:
socket.removeEventListener('message', handler)
Хорошая архитектура отделяет:
Такой подход облегчает поддержку и замену транспорта.
Часто архитектура выглядит следующим образом:
WebSocket
↓
Event Parser
↓
Event Bus
↓
Cache Handlers
↓
TanStack Query Cache
↓
React Components
Подобная схема обеспечивает: