Синхронизация между вкладками

Современные веб-приложения часто открываются одновременно в нескольких вкладках браузера. Пользователь может авторизоваться в одной вкладке, изменить данные в другой, удалить запись в третьей или выполнить logout в четвёртой. Без механизма синхронизации каждая вкладка продолжает жить со своим локальным состоянием кеша.

В контексте TanStack Query это приводит к нескольким проблемам:

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

TanStack Query предоставляет инструменты для синхронизации состояния между вкладками и окнами браузера, позволяя поддерживать единый актуальный кеш.


Архитектура кеша в TanStack Query

Каждый экземпляр QueryClient существует только внутри текущего JavaScript-контекста. Это означает:

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

Пример:

const queryClient = new QueryClient()

Если пользователь открыл приложение в двух вкладках:

  • вкладка A получает список пользователей;
  • вкладка B изменяет пользователя;
  • вкладка A продолжает показывать старый кеш.

Даже если query key совпадают, кеш физически остаётся раздельным.


Broadcast-синхронизация

Для решения этой проблемы используется механизм broadcast-коммуникации между вкладками.

TanStack Query предоставляет экспериментальный пакет:

npm install @tanstack/query-broadcast-client-experimental

Пакет позволяет:

  • распространять изменения кеша;
  • синхронизировать invalidation;
  • пересылать обновления query;
  • поддерживать единое состояние между окнами.

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

Базовая настройка:

import { QueryClient } from '@tanstack/react-query'
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental'

const queryClient = new QueryClient()

broadcastQueryClient({
  queryClient,
  broadcastChannel: 'app-cache'
})

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

  • invalidation в одной вкладке попадёт в остальные;
  • обновление кеша синхронизируется;
  • refetch может запускаться во всех окнах.

BroadcastChannel API

Под капотом используется браузерный API:

new BroadcastChannel('channel-name')

Он позволяет:

  • отправлять сообщения между вкладками;
  • синхронизировать состояние;
  • обмениваться событиями в пределах одного origin.

Пример низкоуровневой работы:

const channel = new BroadcastChannel('app')

channel.postMessage({
  type: 'invalidate',
  key: ['users']
})

channel.onmess age = (event) => {
  console.log(event.data)
}

TanStack Query абстрагирует этот механизм.


Синхронизация invalidation

Наиболее важный сценарий — распространение invalidation.

Пример:

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

Без broadcast:

  • invalidation произойдёт только в текущей вкладке.

С broadcast:

  • остальные вкладки тоже инвалидируют query;
  • stale-состояние синхронизируется;
  • refetch выполняется автоматически.

Синхронизация после mutation

Mutation — основной источник изменений данных.

Пример:

const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ['users']
    })
  }
})

После успешного обновления:

  1. текущая вкладка инвалидирует query;
  2. broadcast отправляет событие;
  3. остальные вкладки получают invalidation;
  4. данные становятся консистентными.

Синхронизация setQueryData

Broadcast работает не только с invalidation, но и с прямым обновлением кеша.

Пример:

queryClient.setQueryData(
  ['user', user.id],
  user
)

Обновление автоматически отправляется другим вкладкам.

Это особенно полезно для:

  • optimistic updates;
  • локальных правок;
  • синхронизации профиля;
  • обновления счётчиков;
  • push-обновлений.

Структура событий синхронизации

Внутри broadcast-клиент передаёт сериализованные события.

Типичные события:

{
  type: 'queryUpdated',
  queryHash: '["users"]',
  state: {}
}

Либо:

{
  type: 'queryRemoved'
}

Либо:

{
  type: 'invalidateQueries'
}

Каждая вкладка подписывается на канал и обновляет локальный кеш.


Ограничения BroadcastChannel

BroadcastChannel работает не во всех условиях.

Основные ограничения:

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

Если вкладка была закрыта во время события, сообщение теряется.


Persist + Broadcast

Часто broadcast используется вместе с persistence-кешем.

Пакет:

npm install @tanstack/react-query-persist-client

Пример:

persistQueryClient({
  queryClient,
  persister
})

Комбинация даёт:

  • синхронизацию между вкладками;
  • восстановление состояния после reload;
  • сохранение кеша между сессиями.

LocalStorage как альтернатива

До появления BroadcastChannel синхронизация часто строилась через localStorage.

Пример:

localStorage.setItem(
  'query-sync',
  JSON.stringify({
    type: 'invalidate',
    key: ['users']
  })
)

Другие вкладки:

window.addEventListener('storage', (event) => {
  console.log(event.newValue)
})

Недостатки подхода:

  • медленнее;
  • сериализация в storage;
  • лишние операции записи;
  • ограниченный размер;
  • более сложная очистка.

BroadcastChannel значительно эффективнее.


Синхронизация авторизации

Один из самых распространённых сценариев — logout.

Пример:

const logout = async () => {
  await api.logout()

  queryClient.clear()
}

Без синхронизации:

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

С broadcast:

  • очистка кеша распространяется;
  • все вкладки одновременно теряют сессию;
  • интерфейс обновляется синхронно.

Синхронизация пользовательских настроек

Пример query:

useQuery({
  queryKey: ['settings'],
  queryFn: fetchSettings
})

Если пользователь меняет тему интерфейса:

queryClient.setQueryData(
  ['settings'],
  updatedSettings
)

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

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

Проблема циклических обновлений

При ручной синхронизации через события можно случайно создать цикл:

  1. вкладка A обновляет кеш;
  2. вкладка B получает событие;
  3. вкладка B снова отправляет событие;
  4. возникает бесконечная синхронизация.

Broadcast-клиент TanStack Query защищает от подобных повторов.


Синхронизация stale-состояния

Важно понимать разницу между:

  • синхронизацией данных;
  • синхронизацией stale/fresh состояния.

Пример:

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

Даже если сами данные не изменились:

  • stale-флаг синхронизируется;
  • другие вкладки узнают о необходимости refetch.

Работа с focusManager

TanStack Query автоматически refetch-ит данные при возврате фокуса вкладки.

Пример механизма:

refetchOnWindowFocus: true

В сочетании с broadcast получается:

  1. вкладка B обновляет данные;
  2. вкладка A становится stale;
  3. пользователь возвращается во вкладку A;
  4. автоматически запускается refetch.

Multi-tab optimistic updates

Optimistic updates становятся сложнее при нескольких вкладках.

Пример:

queryClient.setQueryData(
  ['todos'],
  optimisticTodos
)

Проблемы:

  • другие вкладки тоже увидят optimistic state;
  • rollback должен синхронизироваться;
  • возможны визуальные скачки.

Рекомендуется:

  • использовать минимальные optimistic updates;
  • быстро подтверждать серверным ответом;
  • избегать долгоживущих optimistic-состояний.

Race conditions между вкладками

Типичная ситуация:

  • вкладка A обновляет пользователя;
  • вкладка B почти одновременно удаляет пользователя;
  • обе отправляют invalidation.

Возможные последствия:

  • кратковременное отображение старых данных;
  • конфликт refetch;
  • рассинхронизация UI.

TanStack Query не решает серверные конфликты автоматически. Необходимы:

  • versioning;
  • timestamps;
  • ETag;
  • optimistic concurrency control.

Производительность синхронизации

Broadcast-события имеют стоимость.

Проблемы при большом количестве query:

  • частые сериализации;
  • лишние refetch;
  • высокий объём сообщений;
  • рост нагрузки на CPU.

Особенно заметно при:

  • realtime UI;
  • WebSocket-обновлениях;
  • высокочастотных mutation;
  • больших payload.

Минимизация broadcast-нагрузки

Полезные практики:

Ограничение invalidateQueries

Плохо:

queryClient.invalidateQueries()

Лучше:

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

Точечное обновление кеша

Вместо глобального refetch:

queryClient.setQueryData(
  ['user', id],
  updatedUser
)

Уменьшение количества query

Слишком дробный кеш создаёт:

  • множество событий;
  • частые обновления;
  • лишнюю синхронизацию.

Devtools и multi-tab debugging

При отладке нескольких вкладок полезно наблюдать:

  • stale status;
  • query observers;
  • invalidate events;
  • refetch sequence.

Для этого используются Devtools:

npm install @tanstack/react-query-devtools

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

<ReactQueryDevtools initialIsOpen />

Devtools помогают увидеть:

  • какие query синхронизируются;
  • какие обновления приходят из broadcast;
  • где возникают лишние refetch.

Offline-first и синхронизация

При offline-first архитектуре вкладки могут иметь разные состояния сети.

Пример:

  • вкладка A online;
  • вкладка B offline.

После восстановления соединения:

  • broadcast начинает догонять состояние;
  • query могут refetch-иться массово;
  • mutation queue синхронизируется.

Здесь особенно важны:

  • retry;
  • staleTime;
  • networkMode;
  • persistence.

Синхронизация и WebSocket

Broadcast хорошо сочетается с WebSocket.

Сценарий:

  1. сервер присылает обновление;
  2. одна вкладка получает websocket event;
  3. обновляется query cache;
  4. broadcast распространяет изменения.

Пример:

socket.on('userUpdated', (user) => {
  queryClient.setQueryData(
    ['user', user.id],
    user
  )
})

Остальные вкладки получают обновление без собственных websocket-соединений.


Shared Worker как альтернатива

В крупных приложениях иногда используется Shared Worker.

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

  • единое соединение;
  • централизованный state;
  • общий websocket;
  • меньше сетевых подключений.

Недостатки:

  • сложность;
  • ограниченная поддержка;
  • более тяжёлая архитектура.

Для большинства приложений BroadcastChannel оказывается достаточным решением.


Практическая схема multi-tab архитектуры

Типичная архитектура выглядит так:

Mutation
   ↓
setQueryData / invalidateQueries
   ↓
broadcastQueryClient
   ↓
BroadcastChannel
   ↓
Другие вкладки
   ↓
refetch / cache update

Такой подход обеспечивает:

  • консистентный интерфейс;
  • актуальный кеш;
  • уменьшение числа запросов;
  • синхронное состояние приложения;
  • корректную работу multi-tab UI.