Нормализация данных

Понимание модели данных в RTK Query

В основе RTK Query лежит кэш, организованный не как классическая «нормализованная база», а как набор изолированных записей, привязанных к запросам. Каждый запрос формирует собственный ключ кэша, зависящий от:

  • имени endpoint
  • аргументов запроса
  • сериализации параметров

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


Кэш как форма псевдонормализации

Хотя RTK Query не требует явной нормализации, его внутренняя архитектура уже решает часть задач, которые обычно закрывает нормализация:

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

Каждый запрос существует в кэше как отдельная сущность:

getUsers({ page: 1 })
getUsers({ page: 2 })

Эти данные не объединяются автоматически, но и не конфликтуют.


Ограничения отсутствия классической нормализации

При работе с вложенными структурами возникает типичная проблема:

{
  "id": 1,
  "name": "Alice",
  "posts": [
    { "id": 10, "title": "A" },
    { "id": 11, "title": "B" }
  ]
}

Если один и тот же пост встречается в разных списках пользователей или разных страницах, RTK Query:

  • не дедуплицирует сущности
  • не синхронизирует изменения между разными кешами
  • хранит данные в том виде, в котором они пришли

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


transformResponse как точка нормализации

Одним из основных инструментов является transformResponse. Он позволяет преобразовать данные сразу после получения ответа:

getUsers: builder.query({
  query: () => "/users",
  transformResponse: (response) => {
    return response.map(user => ({
      id: user.id,
      fullName: `${user.firstName} ${user.lastName}`,
    }));
  }
});

На этом уровне можно:

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

Однако важно, что это преобразование локально для endpoint и не влияет на другие кэши.


Интеграция с entity adapter для полной нормализации

Полная нормализация достигается через использование createEntityAdapter из Redux Toolkit.

import { createEntityAdapter } from "@reduxjs/toolkit";

const usersAdapter = createEntityAdapter();

const initialState = usersAdapter.getInitialState();

Далее данные приводятся к форме:

transformResponse: (response) => {
  return usersAdapter.setAll(initialState, response);
}

Структура становится:

{
  ids: [1, 2, 3],
  entities: {
    1: { id: 1, name: "Alice" },
    2: { id: 2, name: "Bob" }
  }
}

Такой подход позволяет:

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

selectFromResult как слой «виртуальной нормализации»

Даже без entity adapter можно имитировать нормализацию на уровне выбора данных:

const selectUsers = api.endpoints.getUsers.select();

const selectUserById = (id) => (result) =>
  result.data?.find(user => user.id === id);

С использованием selectFromResult:

useGetUsersQuery(undefined, {
  selectFromResult: ({ data }) => ({
    user: data?.find(u => u.id === 5)
  })
});

Этот механизм позволяет:

  • извлекать фрагменты данных
  • избегать лишних ререндеров
  • строить локальные представления нормализованных данных

merge и ручная агрегация страниц

При пагинации или infinite scroll возникает задача объединения данных. RTK Query не делает это автоматически, но предоставляет merge:

getPosts: builder.query({
  query: (page) => `/posts?page=${page}`,
  serializeQueryArgs: ({ endpointName }) => endpointName,
  merge: (currentCache, newItems) => {
    currentCache.push(...newItems);
  },
  forceRefetch: ({ currentArg, previousArg }) => {
    return currentArg !== previousArg;
  }
});

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

  • один общий массив в кэше
  • постепенное добавление данных
  • отсутствие дублирования страниц как отдельных сущностей

updateQueryData как механизм синхронизации сущностей

Для поддержания согласованности данных используется updateQueryData:

api.util.updateQueryData("getUsers", undefined, (draft) => {
  const user = draft.find(u => u.id === 1);
  if (user) {
    user.name = "Updated";
  }
});

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

  • изменять кэш без повторного запроса
  • поддерживать консистентность UI
  • вручную синхронизировать «нормализованные» изменения

Псевдонормализация через tags

Система тегов в RTK Query играет ключевую роль в управлении связями данных:

providesTags: (result) =>
  result
    ? [
        ...result.map(({ id }) => ({ type: "User", id })),
        { type: "User", id: "LIST" }
      ]
    : [{ type: "User", id: "LIST" }];

И при мутациях:

invalidatesTags: [{ type: "User", id: "LIST" }]

Это создает логическую сеть зависимостей, которая частично заменяет необходимость глубокой нормализации:

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

serializeQueryArgs и контроль структуры кэша

Ключ к пониманию «как именно нормализуются данные» лежит в сериализации аргументов:

serializeQueryArgs: ({ endpointName, queryArgs }) => {
  return endpointName;
};

Такой подход:

  • объединяет все результаты в один кэш
  • отключает сегментацию по параметрам
  • создает единую «нормализованную» коллекцию

Если же оставить стандартную сериализацию, кэш будет фрагментирован.


Комбинированная стратегия нормализации

В реальных приложениях часто используется гибридный подход:

  • RTK Query отвечает за транспорт и кэширование
  • transformResponse выполняет первичную очистку
  • entity adapter нормализует структуру
  • merge объединяет страницы
  • tags синхронизируют состояния
  • updateQueryData корректирует изменения

Так формируется многоуровневая система, в которой нормализация распределена между несколькими механизмами, а не сосредоточена в одном месте.


Влияние нормализации на производительность

Правильное распределение данных влияет на:

  • количество ререндеров React-компонентов
  • объем пересоздаваемых объектов
  • частоту сетевых запросов
  • глубину сравнения данных

Нормализованные структуры позволяют работать с минимальными изменениями состояния, тогда как денормализованные кэши RTK Query проще, но могут приводить к лишним обновлениям UI при больших массивах данных.


Ограниченность и осознанный компромисс модели

Архитектура RTK Query сознательно избегает жесткой нормализации по умолчанию. Причины:

  • упрощение API
  • снижение сложности состояния
  • предсказуемое поведение кэша
  • отсутствие необходимости в глобальном store для сущностей

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


Сложные структуры и вложенные сущности

При работе с глубоко вложенными объектами часто применяются комбинированные техники:

  • разбиение ответа на сущности в transformResponse
  • хранение связей через id
  • отдельные endpoints для сущностей
  • связывание через selectFromResult

Пример преобразования:

transformResponse: (response) => {
  return {
    users: usersAdapter.setAll(initialState, response.users),
    posts: postsAdapter.setAll(initialState, response.posts),
  };
}

Такой подход позволяет перейти от денормализованного API к структурированной модели внутри клиента.


Практическая модель данных в приложении

В типичной архитектуре RTK Query данные проходят несколько стадий:

  1. Получение сырого ответа API
  2. Преобразование через transformResponse
  3. Разбиение на сущности
  4. Кэширование по endpoint
  5. Объединение через merge (если требуется)
  6. Логическая синхронизация через tags
  7. Выборка через selectFromResult

Эта цепочка формирует распределенную систему нормализации, где каждая часть решает свою задачу, а не дублирует общую ответственность.