TanStack Query изначально проектировался как библиотека для работы с асинхронными запросами и серверным состоянием. Основная модель библиотеки строится вокруг HTTP-запросов, кеширования, фоновых обновлений и повторной синхронизации данных.
Однако современные приложения всё чаще требуют реактивного обновления данных в режиме реального времени:
В подобных системах стандартного механизма polling недостаточно. Постоянные HTTP-запросы создают лишнюю нагрузку и увеличивают задержки.
Интеграция WebSocket позволяет:
TanStack Query не содержит встроенного WebSocket-клиента, однако предоставляет мощные механизмы управления кешем, идеально подходящие для real-time архитектуры.
Распространённая ошибка — попытка полностью отказаться от TanStack Query после подключения WebSocket.
Это создаёт множество проблем:
Правильная архитектура выглядит иначе:
| Ответственность | Инструмент |
|---|---|
| Начальная загрузка данных | TanStack Query |
| Кеширование | TanStack Query |
| Инвалидация | TanStack Query |
| Синхронизация | WebSocket |
| Push-обновления | WebSocket |
| Локальное обновление кеша | QueryClient |
WebSocket становится источником событий, а TanStack Query остаётся системой управления серверным состоянием.
Типичная последовательность работы:
useQuery загружает первоначальные данные.const socket = new WebSocket("ws://localhost:3000")
Обработка подключения:
socket.addEventListener("open", () => {
console.log("connected")
})
Получение сообщений:
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
console.log(data)
})
Главная идея — обновлять кеш напрямую.
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient()
Наиболее распространённый сценарий.
{
"type": "NEW_MESSAGE",
"payload": {
"id": 10,
"text": "Hello"
}
}
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data)
if (message.type === "NEW_MESSAGE") {
queryClient.setQueryData(
["messages"],
(old = []) => {
return [...old, message.payload]
}
)
}
})
Метод:
queryClient.setQueryData(queryKey, updater)
делает следующее:
Многие разработчики после WebSocket-события делают:
queryClient.invalidateQueries({
queryKey: ["messages"]
})
Это вызывает новый HTTP-запрос.
При активном real-time потоке подобный подход создаёт:
Если сервер уже прислал новые данные через WebSocket, повторный HTTP-запрос часто не нужен.
Есть сценарии, где сервер присылает только уведомление об изменении:
{
"type": "POST_UPDATED",
"id": 15
}
Данных недостаточно для обновления кеша.
В этом случае корректно:
queryClient.invalidateQueries({
queryKey: ["post", 15]
})
Очень распространённая архитектура:
| Тип события | Действие |
|---|---|
| Полные данные | setQueryData |
| Сигнал об изменении | invalidateQueries |
| Крупная синхронизация | refetchQueries |
Обычно WebSocket подключается внутри эффекта.
import { useEffect } from "react"
function MessagesSocket() {
useEffect(() => {
const socket = new WebSocket("ws://localhost:3000")
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
queryClient.setQueryData(
["messages"],
(old = []) => [...old, data]
)
})
return () => {
socket.close()
}
}, [])
return null
}
В больших приложениях нельзя создавать WebSocket в каждом компоненте.
Это приводит к:
Правильнее использовать единый provider.
import {
createContext,
useContext,
useEffect,
useRef
} from "react"
const SocketContext = createContext(null)
export function SocketProvider({ children }) {
const socketRef = useRef(null)
useEffect(() => {
const socket = new WebSocket("ws://localhost:3000")
socketRef.current = socket
return () => {
socket.close()
}
}, [])
return (
<SocketContext.Provider value={socketRef}>
{children}
</SocketContext.Provider>
)
}
export function useSocket() {
return useContext(SocketContext)
}
Удобно выносить обработку событий в отдельные хуки.
import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
export function useMessagesSocket(socket) {
const queryClient = useQueryClient()
useEffect(() => {
const handler = (event) => {
const message = JSON.parse(event.data)
queryClient.setQueryData(
["messages"],
(old = []) => [...old, message]
)
}
socket.addEventListener("message", handler)
return () => {
socket.removeEventListener("message", handler)
}
}, [socket, queryClient])
}
Не всегда требуется заменять весь массив.
queryClient.setQueryData(
["posts"],
(old = []) => {
return old.map(post => {
if (post.id === updatedPost.id) {
return {
...post,
...updatedPost
}
}
return post
})
}
)
Если существует запрос:
["post", id]
то обновление выглядит так:
queryClient.setQueryData(
["post", updatedPost.id],
updatedPost
)
Частая проблема — обновление только одной части кеша.
Например:
Правильный подход:
queryClient.setQueryData(
["posts"],
updatePosts
)
queryClient.setQueryData(
["post", updatedPost.id],
updatedPost
)
queryClient.setQueryData(
["messages"],
(old = []) => {
return old.filter(
message => message.id !== deletedId
)
}
)
queryClient.setQueryData(
["notifications"],
(old = []) => {
return [newNotification, ...old]
}
)
Интеграция WebSocket с infinite queries требует отдельной логики.
Структура infinite query:
{
pages: [],
pageParams: []
}
queryClient.setQueryData(
["feed"],
(oldData) => {
if (!oldData) return oldData
return {
...oldData,
pages: oldData.pages.map(page => {
return {
...page,
items: page.items.map(item => {
if (item.id === updated.id) {
return updated
}
return item
})
}
})
}
}
)
queryClient.setQueryData(
["feed"],
(oldData) => {
if (!oldData) return oldData
const firstPage = oldData.pages[0]
return {
...oldData,
pages: [
{
...firstPage,
items: [newItem, ...firstPage.items]
},
...oldData.pages.slice(1)
]
}
}
)
Практически все real-time системы используют event-driven подход.
{
"type": "MESSAGE_CREATED",
"payload": {}
}
const handlers = {
MESSAGE_CREATED: handleMessageCreated,
MESSAGE_UPDATED: handleMessageUpdated,
MESSAGE_DELETED: handleMessageDeleted
}
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
const handler = handlers[data.type]
if (handler) {
handler(data.payload)
}
})
WebSocket-системы иногда отправляют повторяющиеся события.
Без дедупликации интерфейс может содержать дубликаты.
queryClient.setQueryData(
["messages"],
(old = []) => {
const exists = old.some(
message => message.id === newMessage.id
)
if (exists) {
return old
}
return [...old, newMessage]
}
)
Иногда сообщения приходят в неправильном порядке.
Например:
Это особенно часто возникает при reconnect.
queryClient.setQueryData(
["posts"],
(old = []) => {
return old.map(post => {
if (post.id !== updated.id) {
return post
}
if (
post.updatedAt >
updated.updatedAt
) {
return post
}
return updated
})
}
)
WebSocket-соединения могут разрываться:
function connect() {
const socket = new WebSocket(
"ws://localhost:3000"
)
socket.oncl ose = () => {
setTimeout(() => {
connect()
}, 3000)
}
}
Постоянные reconnect-попытки могут перегрузить сервер.
Используется backoff:
let retries = 0
function connect() {
const socket = new WebSocket(
"ws://localhost:3000"
)
socket.oncl ose = () => {
retries += 1
const timeout =
Math.min(1000 * 2 ** retries, 30000)
setTimeout(connect, timeout)
}
socket.ono pen = () => {
retries = 0
}
}
После восстановления соединения кеш может устареть.
Часто выполняется:
socket.ono pen = () => {
queryClient.invalidateQueries()
}
Иногда выгоднее обновлять только часть кеша.
queryClient.invalidateQueries({
queryKey: ["notifications"]
})
queryClient.invalidateQueries({
queryKey: ["messages"]
})
Некоторые серверы закрывают неактивные соединения.
Используется heartbeat:
setInterval(() => {
socket.send(
JSON.stringify({
type: "PING"
})
)
}, 30000)
Обычно токен передаётся:
const socket = new WebSocket(
`ws://localhost:3000?token=${token}`
)
При refresh токена может понадобиться reconnect.
authStore.subscribe(() => {
socket.close()
connect()
})
TanStack Query отлично поддерживает optimistic updates.
Но при WebSocket возможен конфликт:
Часто используются:
const optimisticMessage = {
id: crypto.randomUUID(),
pending: true,
text
}
После ответа сервера:
queryClient.setQueryData(
["messages"],
(old = []) => {
return old.map(message => {
if (message.id === optimisticId) {
return serverMessage
}
return message
})
}
)
TanStack Query позволяет подписываться на изменения кеша.
Это полезно для интеграции сложных real-time систем.
const unsubscribe =
queryClient
.getQueryCache()
.subscribe((event) => {
console.log(event)
})
Можно отслеживать:
Если приложение открыто в нескольких вкладках:
Современный подход — использовать:
const channel =
new BroadcastChannel("app")
Одна вкладка получает WebSocket-события и рассылает их остальным.
socket.onmess age = (event) => {
const data = JSON.parse(event.data)
channel.postMessage(data)
}
Другие вкладки:
channel.onmess age = (event) => {
handleSocketEvent(event.data)
}
При постоянных push-обновлениях часто увеличивают
staleTime.
useQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
staleTime: Infinity
})
Причина:
Полный отказ от refetch может привести к:
Поэтому даже с WebSocket обычно оставляют:
Некоторые системы используют гибрид:
| Механизм | Назначение |
|---|---|
| WebSocket | Мгновенные обновления |
| Polling | Проверка целостности |
| Refetch | Восстановление синхронизации |
TanStack Query легко работает с Socket.IO.
import { io } from "socket.io-client"
const socket = io("http://localhost:3000")
socket.on("message_created", (message) => {
queryClient.setQueryData(
["messages"],
(old = []) => [...old, message]
)
})
Socket.IO поддерживает acknowledgements.
socket.emit(
"create_message",
payload,
(response) => {
console.log(response)
}
)
На сервере WebSocket недоступен.
Поэтому соединение создаётся только на клиенте.
if (typeof window !== "undefined") {
connectSocket()
}
Наиболее распространённая причина memory leaks:
useEffect(() => {
const socket = new WebSocket(url)
const handler = (event) => {}
socket.addEventListener(
"message",
handler
)
return () => {
socket.removeEventListener(
"message",
handler
)
socket.close()
}
}, [])
В production-приложениях интеграция обычно включает:
| Компонент | Назначение |
|---|---|
| TanStack Query | Серверное состояние |
| WebSocket | Real-time события |
| Event Router | Маршрутизация событий |
| QueryClient | Обновление кеша |
| Reconnect Layer | Восстановление соединения |
| Auth Layer | Авторизация |
| BroadcastChannel | Multi-tab sync |
| Periodic Refetch | Проверка целостности |
Состояние должно храниться в Query Cache.
Компоненты должны читать данные только из TanStack Query.
Повторное событие не должно ломать состояние.
Разрывы соединений происходят постоянно.
Если данные уже пришли через WebSocket, лучше обновлять кеш напрямую.
Необходимо учитывать: