Динамические теги

Динамические теги в RTK Query позволяют формировать зависимости между кэшированными данными не статически, а на основе результата запроса, параметров или состояния выполнения endpoint. Такой подход делает систему инвалидации гибкой и масштабируемой.

Статические теги подходят только для простых случаев:

providesTags: ['Post']

Однако в реальных приложениях обычно требуется:

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

Именно для этого используются динамические теги.


Общий принцип работы

RTK Query позволяет передавать в providesTags и invalidatesTags не только массив строк, но и функцию.

Сигнатура функции:

(result, error, arg) => tags

Аргументы:

Аргумент Описание
result успешный результат запроса
error объект ошибки
arg аргумент endpoint

Динамические теги в providesTags

Генерация тегов на основе результата

Наиболее распространённый сценарий — создание тегов для каждой сущности из списка.

Пример API:

[
  { "id": 1, "title": "Post 1" },
  { "id": 2, "title": "Post 2" },
  { "id": 3, "title": "Post 3" }
]

Endpoint:

getPosts: builder.query({
  query: () => '/posts',

  providesTags: (result) =>
    result
      ? [
          ...result.map(post => ({
            type: 'Post',
            id: post.id
          })),
          { type: 'Post', id: 'LIST' }
        ]
      : [{ type: 'Post', id: 'LIST' }]
})

Что происходит в этом примере

После успешного запроса RTK Query создаёт набор тегов:

[
  { type: 'Post', id: 1 },
  { type: 'Post', id: 2 },
  { type: 'Post', id: 3 },
  { type: 'Post', id: 'LIST' }
]

Теперь кэш знает:

  • какие конкретно посты находятся в ответе;
  • какой запрос представляет общий список.

Это позволяет:

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

Специальный тег LIST

Конструкция:

{ type: 'Post', id: 'LIST' }

не является встроенной особенностью RTK Query. Это соглашение, которое используется разработчиками для обозначения списка.

RTK Query воспринимает 'LIST' как обычный идентификатор.

Можно использовать любое значение:

{ type: 'Post', id: 'ALL' }

или:

{ type: 'Post', id: 'COLLECTION' }

Но 'LIST' считается общепринятым вариантом.


Инвалидация конкретной сущности

Mutation может инвалидировать только изменённый объект.

Пример:

updatePost: builder.mutation({
  query: ({ id, ...patch }) => ({
    url: `/posts/${id}`,
    method: 'PATCH',
    body: patch
  }),

  invalidatesTags: (result, error, arg) => [
    { type: 'Post', id: arg.id }
  ]
})

Как работает такая инвалидация

Предположим, ранее был выполнен запрос:

useGetPostsQuery()

Он зарегистрировал теги:

[
  { type: 'Post', id: 1 },
  { type: 'Post', id: 2 },
  { type: 'Post', id: 3 }
]

После выполнения mutation:

updatePost({ id: 2, title: 'Updated' })

RTK Query:

  1. находит все кэши с тегом:
{ type: 'Post', id: 2 }
  1. помечает их устаревшими;

  2. автоматически запускает refetch.

При этом остальные сущности не затрагиваются.


Инвалидация списка после создания записи

При создании нового элемента обычно инвалидируется весь список.

addPost: builder.mutation({
  query: (body) => ({
    url: '/posts',
    method: 'POST',
    body
  }),

  invalidatesTags: [
    { type: 'Post', id: 'LIST' }
  ]
})

Почему используется LIST

После создания новой записи:

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

Инвалидация только одного ID здесь не поможет, потому что новый объект ещё отсутствует в кэше списка.


Комбинирование LIST и ID

Иногда mutation изменяет одновременно:

  • конкретную сущность;
  • состав списка.

Пример:

invalidatesTags: (result, error, arg) => [
  { type: 'Post', id: arg.id },
  { type: 'Post', id: 'LIST' }
]

Такой подход применяется:

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

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

RTK Query передаёт аргумент endpoint третьим параметром.

Пример:

getPost: builder.query({
  query: (id) => `/posts/${id}`,

  providesTags: (result, error, id) => [
    { type: 'Post', id }
  ]
})

Практический смысл arg

Даже если сервер вернул неполный объект:

{
  "title": "Post"
}

endpoint всё равно знает:

id === 15

Следовательно, можно безопасно формировать тег.


Динамические теги и пагинация

Проблема пагинации

Без динамических тегов страницы конфликтуют между собой.

Например:

useGetPostsQuery(1)
useGetPostsQuery(2)

Если обе страницы используют:

providesTags: ['Post']

то инвалидируется весь кэш сразу.


Разделение страниц через динамические теги

Пример:

getPosts: builder.query({
  query: (page) => `/posts?page=${page}`,

  providesTags: (result, error, page) => [
    { type: 'PostPage', id: page }
  ]
})

Теперь каждая страница имеет собственный тег:

{ type: 'PostPage', id: 1 }
{ type: 'PostPage', id: 2 }

Инвалидация только нужной страницы

invalidatesTags: (result, error, arg) => [
  { type: 'PostPage', id: arg.page }
]

Это уменьшает количество лишних refetch.


Теги для фильтрации

Динамические теги особенно полезны при сложной фильтрации.

Пример:

getPosts: builder.query({
  query: ({ status }) => `/posts?status=${status}`,

  providesTags: (result, error, arg) => [
    { type: 'PostFilter', id: arg.status }
  ]
})

Теперь:

useGetPostsQuery({ status: 'draft' })

и:

useGetPostsQuery({ status: 'published' })

получают разные теги.


Составные идентификаторы

Иногда одного параметра недостаточно.

Можно использовать составные ключи:

providesTags: (result, error, arg) => [
  {
    type: 'PostFilter',
    id: `${arg.status}-${arg.page}`
  }
]

или:

id: JSON.stringify(arg)

Использование JSON.stringify

Пример:

providesTags: (result, error, filters) => [
  {
    type: 'Posts',
    id: JSON.stringify(filters)
  }
]

Для:

{
  status: 'active',
  sort: 'date',
  page: 2
}

получится:

{
  type: 'Posts',
  id: '{"status":"active","sort":"date","page":2}'
}

Недостатки JSON.stringify

У такого подхода есть проблемы:

Зависимость от порядка свойств

{
  a: 1,
  b: 2
}

и:

{
  b: 2,
  a: 1
}

могут давать разные строки.


Большие ключи

Сложные объекты создают длинные идентификаторы.


Невозможность частичной инвалидации

Нельзя отдельно инвалидировать:

  • только status;
  • только page;
  • только sort.

Более безопасный подход

Лучше явно формировать ключ:

id: [
  arg.status,
  arg.sort,
  arg.page
].join(':')

Динамические теги и normalize-подход

RTK Query не требует нормализации данных, но динамические теги позволяют приблизиться к entity-based архитектуре.

Пример:

providesTags: (result) =>
  result
    ? result.ids.map(id => ({
        type: 'Post',
        id
      }))
    : []

Особенно полезно при использовании:

  • createEntityAdapter;
  • селекторов;
  • частичных обновлений;
  • optimistic updates.

Инвалидация после удаления

Удаление — один из наиболее важных сценариев для динамических тегов.

Пример:

deletePost: builder.mutation({
  query: (id) => ({
    url: `/posts/${id}`,
    method: 'DELETE'
  }),

  invalidatesTags: (result, error, id) => [
    { type: 'Post', id },
    { type: 'Post', id: 'LIST' }
  ]
})

Почему инвалидируется и ID, и LIST

Удаление влияет сразу на два состояния:

Конкретная сущность

{ type: 'Post', id }

Нужно удалить или обновить детальную страницу.


Коллекция

{ type: 'Post', id: 'LIST' }

Нужно убрать элемент из списка.


Динамические теги и optimistic update

При optimistic update теги используются для синхронизации после подтверждения сервера.

Пример:

updatePost: builder.mutation({
  query: ({ id, ...patch }) => ({
    url: `/posts/${id}`,
    method: 'PATCH',
    body: patch
  }),

  async onQueryStarted(arg, { dispatch, queryFulfilled }) {
    const patchResult = dispatch(
      api.util.updateQueryData(
        'getPost',
        arg.id,
        draft => {
          Object.assign(draft, arg)
        }
      )
    )

    try {
      await queryFulfilled
    } catch {
      patchResult.undo()
    }
  },

  invalidatesTags: (result, error, arg) => [
    { type: 'Post', id: arg.id }
  ]
})

Зачем invalidatesTags при optimistic update

Optimistic update обновляет локальный кэш, но:

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

Инвалидация гарантирует окончательную синхронизацию.


Условные динамические теги

Функция может возвращать разные теги в зависимости от результата.

Пример:

providesTags: (result, error, arg) => {
  if (error) {
    return ['Error']
  }

  return [
    { type: 'Post', id: arg }
  ]
}

Возврат пустого массива

Если endpoint не должен участвовать в инвалидации:

providesTags: () => []

или:

invalidatesTags: () => []

Обработка undefined result

При ошибке result может быть undefined.

Безопасный вариант:

providesTags: (result) =>
  result
    ? result.map(post => ({
        type: 'Post',
        id: post.id
      }))
    : []

Частая ошибка при map

Неправильно:

providesTags: (result) => [
  result.map(post => ({
    type: 'Post',
    id: post.id
  }))
]

Результат:

[
  [
    { type: 'Post', id: 1 },
    { type: 'Post', id: 2 }
  ]
]

RTK Query ожидает плоский массив.

Правильно:

[
  ...result.map(...)
]

Использование helper-функций

В крупных проектах генерацию тегов выносят отдельно.

Пример:

const providesList = (type) => (result) =>
  result
    ? [
        ...result.map(({ id }) => ({ type, id })),
        { type, id: 'LIST' }
      ]
    : [{ type, id: 'LIST' }]

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

providesTags: providesList('Post')

Helper для одиночной сущности

const providesById = (type) => (result, error, id) => [
  { type, id }
]

Универсальный helper invalidation

const invalidatesById = (type) => (result, error, id) => [
  { type, id }
]

Масштабирование tagTypes

При динамических тегах особенно важно правильно проектировать tagTypes.

Пример:

tagTypes: [
  'Post',
  'User',
  'Comment',
  'Category'
]

Антипаттерн: слишком общий tag type

Плохо:

tagTypes: ['Data']

Это приводит к:

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

Хорошая стратегия именования

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

  • один тип — одна сущность;
  • понятные названия;
  • единый стиль.

Пример:

'Post'
'PostList'
'PostPage'
'PostFilter'

Динамические теги и производительность

Правильно организованные теги:

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

Когда динамические теги особенно важны

Они практически обязательны при:

  • больших списках;
  • пагинации;
  • бесконечной прокрутке;
  • фильтрации;
  • сложных CRUD-сценариях;
  • realtime-обновлениях;
  • optimistic update;
  • entity-based архитектуре;
  • сложной инвалидации кэша;
  • многопользовательских интерфейсах.

Основная идея динамических тегов

RTK Query использует теги как систему зависимостей между запросами и mutation.

Статические теги описывают зависимость грубо.

Динамические теги позволяют:

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