Обновление кеша при получении событий

TanStack Query ориентирован на модель получения данных по запросу, однако современные приложения часто работают с событиями в реальном времени. Сервер может отправлять обновления через WebSocket, Server-Sent Events, MQTT, GraphQL Subscriptions или собственный транспорт событий. В таких системах кеш необходимо обновлять без повторного запроса.

События позволяют:

  • синхронизировать данные между пользователями;
  • мгновенно обновлять интерфейс;
  • уменьшать количество HTTP-запросов;
  • поддерживать live-режимы;
  • получать push-обновления от сервера.

Типичные примеры:

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

TanStack Query предоставляет несколько механизмов обновления кеша:

  • queryClient.setQueryData
  • queryClient.setQueriesData
  • queryClient.invalidateQueries
  • queryClient.refetchQueries
  • queryClient.removeQueries

Главным инструментом при обработке событий обычно становится setQueryData.


Архитектура обновлений по событиям

Наиболее распространённая схема выглядит следующим образом:

  1. Клиент получает данные через useQuery.
  2. Сервер отправляет событие.
  3. Обработчик события обновляет кеш.
  4. Все подписанные компоненты автоматически перерисовываются.
const query = useQuery({
    queryKey: ['messages'],
    queryFn: fetchMessages
})

После получения события:

queryClient.setQueryData(['messages'], updater)

TanStack Query самостоятельно уведомляет все компоненты, использующие этот ключ.


Подключение WebSocket

Простейший вариант подключения:

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
        }
    })
})

Подобная схема используется:

  • в чатах;
  • в CRM;
  • в системах заказов;
  • в kanban-досках;
  • в админ-панелях.

Удаление элементов по событию

Пример удаления записи:

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
        }
    })
})

Массовое обновление через setQueriesData

Когда требуется обновить группу запросов:

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
                }
            })
        })
    }
})

Добавление элементов в infinite query

Пример вставки нового элемента в первую страницу:

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]
})

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

Иногда событие содержит недостаточно информации для локального обновления.

Пример:

{
    type: 'report.generated'
}

В таком случае лучше выполнить инвалидирование:

queryClient.invalidateQueries({
    queryKey: ['reports']
})

После этого TanStack Query выполнит refetch.


Частичное обновление против refetch

setQueryData

Преимущества:

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

Недостатки:

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

invalidateQueries

Преимущества:

  • простота;
  • гарантированная актуальность;
  • меньше логики на клиенте.

Недостатки:

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

В реальных приложениях оба подхода комбинируются.


Обновление при reconnect

После потери соединения часть событий может быть пропущена.

Типичная стратегия:

  1. WebSocket переподключается.
  2. Выполняется invalidateQueries.
  3. Клиент синхронизирует состояние.

Пример:

socket.addEventListener('open', () => {
    queryClient.invalidateQueries({
        queryKey: ['messages']
    })
})

Обработка порядка событий

События могут приходить не по порядку.

Пример проблемы:

  1. task.updated version=3
  2. task.updated version=2

Без проверки более старое событие затрёт новое состояние.

Решение:

queryClient.setQueryData(['task', task.id], (old) => {
    if (!old) {
        return task
    }

    if (task.version < old.version) {
        return old
    }

    return {
        ...old,
        ...task
    }
})

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

Вместо версии часто применяют временные метки.

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'
})

Теперь обновление кеша будет распространяться между вкладками.


Интеграция с Server-Sent Events

Пример подключения:

const source = new EventSource('/events')

Обработка:

source.addEventListener('message', (event) => {
    const data = JSON.parse(event.data)

    queryClient.setQueryData(['notifications'], (old = []) => {
        return [data, ...old]
    })
})

Интеграция с GraphQL Subscriptions

Пример через Apollo:

subscription.onMessage((event) => {
    queryClient.setQueryData(['chat'], (old = []) => {
        return [...old, event.message]
    })
})

TanStack Query не зависит от конкретного транспорта.


Хранение соединения

WebSocket обычно создаётся:

  • в provider;
  • в отдельном сервисе;
  • внутри custom hook;
  • в state manager.

Пример custom hook:

function useSocket() {
    useEffect(() => {
        const socket = new WebSocket('wss://example.com')

        return () => {
            socket.close()
        }
    }, [])
}

Подписка на события внутри React

Иногда удобно создавать 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])
}

Централизованный event bus

В крупных приложениях используется промежуточный слой:

eventBus.on('task.updated', updateTask)
eventBus.on('message.created', updateMessage)

Преимущества:

  • разделение ответственности;
  • тестируемость;
  • масштабируемость;
  • независимость транспорта.

Работа с optimistic updates и событиями

Частая проблема — конфликт optimistic update и серверного события.

Сценарий:

  1. Клиент меняет статус задачи.
  2. Кеш обновляется оптимистично.
  3. Сервер отправляет событие.
  4. Происходит двойное обновление.

Для решения используют:

  • версии;
  • transaction id;
  • reconciliation;
  • merge-логику.

Пример:

if (incoming.txId === optimisticTxId) {
    return old
}

Обработка отключения соединения

При потере WebSocket:

socket.addEventListener('close', () => {
    console.log('Disconnected')
})

Типичные стратегии:

  • reconnect с backoff;
  • fallback на polling;
  • временное invalidateQueries;
  • offline queue.

Reconnect с экспоненциальной задержкой

Пример:

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'
        }
    }
}

Стратегия eventual consistency

События в реальном времени не гарантируют абсолютную синхронность.

Поэтому приложения часто комбинируют:

  • локальные обновления;
  • периодический refetch;
  • invalidateQueries;
  • background synchronization.

Это снижает вероятность накопления ошибок состояния.


Подходы к организации ключей

События проще обрабатывать при предсказуемой структуре 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)

Разделение transport layer и cache layer

Хорошая архитектура отделяет:

Транспорт

  • WebSocket;
  • SSE;
  • MQTT;
  • GraphQL subscriptions.

Управление кешем

  • update handlers;
  • reconciliation;
  • normalization;
  • invalidation.

Такой подход облегчает поддержку и замену транспорта.


Типичная схема production-приложения

Часто архитектура выглядит следующим образом:

WebSocket
    ↓
Event Parser
    ↓
Event Bus
    ↓
Cache Handlers
    ↓
TanStack Query Cache
    ↓
React Components

Подобная схема обеспечивает:

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