Middleware и interceptors

Архитектура TanStack Query построена вокруг централизованных сущностей QueryCache и MutationCache, через которые проходит весь поток асинхронных операций. Это позволяет реализовывать поведение, аналогичное middleware и interceptors, без встроенной классической системы перехватчиков уровня Axios или Express.

Вместо единой точки перехвата запросов библиотека предоставляет несколько уровней расширения поведения:

  • глобальные настройки QueryClient
  • перехват через queryCache и mutationCache
  • обёртки queryFn и mutationFn
  • подписки на события кеша
  • дефолтные настройки через setQueryDefaults и setMutationDefaults

Каждый уровень решает свою задачу и формирует многоступенчатую модель обработки запроса.


Модель выполнения запроса как цепочка middleware

Любой запрос в TanStack Query проходит последовательность этапов:

  1. Инициализация query/mutation
  2. Проверка кеша
  3. Выполнение queryFn или mutationFn
  4. Обработка результата
  5. Запись в кеш
  6. Триггер подписчиков (components, observers)
  7. Побочные эффекты (invalidate, refetch, retry)

Middleware-подход в данной модели реализуется через вмешательство в каждый этап, но чаще всего — через перехват на уровне функций и событий кеша.


Глобальные настройки как базовый interceptor-слой

Наиболее простой способ задать “перехватывающее” поведение — использование QueryClient с дефолтными опциями.

import { QueryClient } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error.status === 401) return false
        return failureCount < 3
      },
      staleTime: 1000 * 30,
    },
  },
})

Здесь retry выступает как middleware-логика, которая перехватывает ошибку до повторного запроса.

Аналогично можно централизовать обработку ошибок:

defaultOptions: {
  queries: {
    onError: (error) => {
      console.log('Global query error:', error)
    }
  }
}

Такой подход задаёт единый слой поведения для всех запросов.


setQueryDefaults и setMutationDefaults как декларативные интерсепторы

Более точечный механизм — настройка поведения по ключу query.

queryClient.setQueryDefaults(['users'], {
  queryFn: fetchUsers,
  staleTime: 1000 * 60,
  retry: 2,
})

Фактически это аналог middleware, привязанного к маршруту:

  • ключ ['users'] — аналог route pattern
  • queryFn — основной handler
  • остальные параметры — поведение вокруг выполнения

Для мутаций:

queryClient.setMutationDefaults(['updateUser'], {
  mutationFn: updateUser,
  retry: 1,
  onError: (err) => {
    console.error('Update failed', err)
  },
})

Это позволяет централизовать бизнес-логику без дублирования в компонентах.


Перехват через queryCache.subscribe

Низкоуровневый механизм middleware реализуется через подписку на события кеша.

queryClient.getQueryCache().subscribe((event) => {
  console.log(event.type, event.query?.queryKey)
})

События включают:

  • added
  • removed
  • updated
  • observerAdded
  • observerRemoved

Этот уровень ближе всего к настоящему interceptor pipeline, так как позволяет реагировать на изменения состояния запроса независимо от источника.


MutationCache как слой перехвата мутаций

Для мутаций используется отдельный кеш:

queryClient.getMutationCache().subscribe((event) => {
  console.log('mutation event:', event.type)
})

Это позволяет централизованно отслеживать:

  • начало мутации
  • успех
  • ошибку
  • завершение

Пример обработки глобальных side effects:

queryClient.getMutationCache().subscribe((event) => {
  if (event.type === 'updated') {
    const mutation = event.mutation

    if (mutation.state.status === 'error') {
      showToast('Mutation failed')
    }
  }
})

Middleware через обёртку queryFn

Самый гибкий способ реализовать interceptors — обёртка над queryFn.

const withAuth = (fn) => {
  return async (context) => {
    const token = localStorage.getItem('token')

    return fn({
      ...context,
      headers: {
        ...context.headers,
        Authorization: `Bearer ${token}`,
      },
    })
  }
}

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

const fetchUsers = withAuth(async ({ headers }) => {
  const res = await fetch('/api/users', { headers })
  if (!res.ok) throw res
  return res.json()
})

Такой подход реализует классическую middleware-цепочку:

  • входящий контекст
  • модификация запроса
  • выполнение оригинальной функции
  • возврат результата

Композиция middleware

Несколько middleware можно комбинировать:

const compose = (...fns) => {
  return (initial) =>
    fns.reduceRight((acc, fn) => fn(acc), initial)
}

Пример:

const withAuth = (fn) => async (ctx) => {
  const token = 'abc'
  return fn({ ...ctx, token })
}

const withLogging = (fn) => async (ctx) => {
  console.log('Request started')
  const result = await fn(ctx)
  console.log('Request finished')
  return result
}

const fetchUsers = compose(
  withLogging,
  withAuth
)(async ({ token }) => {
  const res = await fetch('/api/users', {
    headers: { Authorization: token },
  })
  return res.json()
})

Такой подход полностью повторяет middleware pipeline из серверных фреймворков.


Interceptors на уровне side effects

TanStack Query активно использует side effects через:

  • onSuccess
  • onError
  • onSettled

Эти колбэки формируют встроенную систему перехвата результата.

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  onSuccess: (data) => {
    cacheUsers(data)
  },
  onError: (error) => {
    reportError(error)
  },
})

Важно, что эти interceptors локальны, но могут быть вынесены в глобальные defaults.


Глобальная обработка ошибок как interceptor layer

Одной из ключевых задач middleware является централизованная обработка ошибок.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      onError: (error) => {
        if (error.status === 401) {
          logout()
        }

        if (error.status >= 500) {
          sendToMonitoring(error)
        }
      },
    },
  },
})

Это создаёт глобальный interception layer, аналогичный HTTP interceptor в Axios.


Перехват через queryClient.fetchQuery

Метод fetchQuery позволяет внедрять middleware-логику до попадания данных в кеш.

await queryClient.fetchQuery({
  queryKey: ['users'],
  queryFn: async () => {
    console.log('before fetch')
    const data = await fetch('/api/users').then(r => r.json())
    console.log('after fetch')
    return data
  },
})

Этот слой важен тем, что:

  • работает независимо от React
  • всегда использует cache pipeline
  • проходит все внутренние механизмы Query

Псевдо-interceptors через invalidate и refetch

Система инвалидирования кеша может выступать как реактивный interceptor.

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

Это запускает цепочку:

  • пометка данных как stale
  • возможный refetch
  • уведомление подписчиков

Можно рассматривать invalidate как “сигнал middleware”, запускающий downstream обработку.


Расширение QueryClient как точка внедрения middleware

Создание кастомного клиента позволяет внедрять централизованное поведение:

class AppQueryClient extends QueryClient {
  constructor(options) {
    super(options)

    this.getQueryCache().subscribe((event) => {
      this.log(event)
    })
  }

  log(event) {
    if (process.env.NODE_ENV !== 'production') {
      console.log('[Query event]', event)
    }
  }
}

Такой подход превращает клиент в middleware-container.


Разделение уровней перехвата

В TanStack Query можно выделить уровни аналогичные middleware stack:

Уровень 1: QueryClient defaults

  • retry
  • staleTime
  • global callbacks

Уровень 2: Query/Mutation defaults по ключу

  • бизнес-логика
  • endpoint-specific behavior

Уровень 3: queryFn/mutationFn wrappers

  • auth
  • logging
  • transformation

Уровень 4: Cache subscriptions

  • наблюдение за состоянием системы

Уровень 5: Side effects callbacks

  • onSuccess/onError/onSettled

Каждый уровень дополняет предыдущий и не заменяет его.


Ограничения middleware-подхода в TanStack Query

Несмотря на гибкость, существуют ограничения:

  • нет встроенного chain-of-responsibility механизма
  • отсутствует приоритетность middleware
  • события кеша не гарантируют строгий порядок обработки
  • queryFn остаётся главным источником контроля

Поэтому архитектура всегда остаётся гибридной: декларативной + функциональной.


Практическая модель построения interceptor-слоя

В реальных приложениях чаще всего используется комбинация:

  • queryFn wrapper для auth и трансформаций
  • setQueryDefaults для бизнес-логики
  • onError для глобальной обработки ошибок
  • queryCache.subscribe для мониторинга
  • mutationCache.subscribe для сайд-эффектов

Эта комбинация формирует устойчивую middleware-систему поверх реактивного кеша TanStack Query.