Любое React-приложение, работающее со связанными данными — пользователи, задачи, комментарии, проекты — рано или поздно упирается в одну и ту же проблему: как поддерживать консистентность одной и той же сущности, отображаемой в десятке разных компонентов. Обновили имя пользователя в диалоге — а в шапке, сайдбаре, карточках задач и выпадающем списке по-прежнему старое значение. Начинаете пробрасывать пропсы, поднимать состояние, дублировать запросы. Это работает до определённого масштаба, а потом превращается в спагетти.
В этой статье я разберу паттерн Entity Registry — плоский реестр сущностей на базе Zustand, который автоматически нормализует любые ответы API, хранит данные в едином словаре по ID и обеспечивает точечный ре-рендер только тех компонентов, чьи данные действительно изменились. Отдельно разберём трюк с enumerable: false для мягких удалений — пожалуй, самую изящную часть паттерна.
Суть подхода
Вместо того чтобы хранить данные в том виде, в котором они пришли от сервера (вложенные объекты, массивы с дублями), мы извлекаем из ответа все сущности и складываем их в плоский словарь, где ключом является ID. Компоненты подписываются на конкретные сущности через селекторы Zustand и перерисовываются только при изменении своей части данных.
Контракт минимален: каждая сущность должна содержать поле id и поле entityType. Реестр использует эти два свойства, чтобы понять, что перед ним и куда это положить.
Определение типов сущностей
Начнём с перечисления типов и базового интерфейса, предполагая, что фронт-енд, бек-енд и база данных оперирует только двумя сущностями: юзеры и таски для реализации канбан доски.
enum EntityType { user = 'user', task = 'task', } interface BaseEntity { id: string; entityType: EntityType; }
В реальном проекте EntityType может генерироваться из Prisma, GraphQL codegen или любого другого источника правды. Главное — чтобы ответы вашего API содержали эти поля.
Для type-safety можно использовать branded-типы, чтобы на этапе компиляции исключить путаницу между ID разных сущностей в компонентах и других функциях:
type BrandedId<T extends string> = string & { readonly __brand: T }; type UserId = BrandedId<'user'>; type TaskId = BrandedId<'task'>;
Теперь передать TaskId туда, где ожидается UserId, не получится — TypeScript не позволит.
Хранилище реестра
Интерфейс стора прост: по ключу EntityType лежат словари конкретных сущностей (Record<UserId, UserType>, Record<TaskId, TaskType>), плюс метод parse:
import { create } from 'zustand'; interface Registry { [EntityType.user]: Record<UserId, UserType>; [EntityType.task]: Record<TaskId, TaskType>; parse: (data: unknown) => void; }
Всё интересное происходит внутри parse. Но сначала — функция извлечения.
Рекурсивное извлечение: getEntitiesFromData
Ядро паттерна — рекурсивная функция, которая обходит произвольную структуру данных и вытаскивает из неё все объекты, содержащие entityType + id:
const MAX_DEPTH = 10; function getEntitiesFromData(data: unknown, entities = {}, depth = 0) { if (depth > MAX_DEPTH) return entities; if (Array.isArray(data)) { data.forEach((item) => getEntitiesFromData(item, entities, depth + 1)); } else if (typeof data === 'object' && data !== null) { Object.values(data).forEach((value) => getEntitiesFromData(value, entities, depth + 1) ); if ('entityType' in data && 'id' in data) { const { entityType, id } = data; entities[entityType] ??= {}; entities[entityType][id] = data; } } return entities; }
Допустим, API возвращает такой ответ:
{ "tasks": [ { "id": "task-1", "title": "Сделать рефакторинг", "entityType": "task", "user": { "id": "user-1", "fullName": "Иван Петров", "entityType": "user" } } ] }
После одного вызова getEntitiesFromData мы получаем плоскую нормализованную структуру:
{ task: { "task-1": { id: "task-1", title: "Сделать рефакторинг", entityType: "task", ... } }, user: { "user-1": { id: "user-1", fullName: "Иван Петров", entityType: "user" } } }
Вложенные сущности извлекаются наравне с корневыми — один вызов нормализует весь ответ целиком, независимо от глубины вложенности (максимальную глубину можно установить с помощью константы MAX_DEPTH).
Метод parse
Метод parse вызывает getEntitiesFromData, а затем мержит результат в стор. Для каждой входящей сущности он:
Проверяет, есть ли сущность с таким ID в текущем состоянии.
Выполняет глубокое сравнение старого и нового значения через fast-deep-equal. Если ничего не изменилось — обновление пропускается, ре-рендера не происходит.
Записывает обновлённые сущности как дескрипторы свойств (property descriptors), а удалённые помечает как
enumerable: false.
Результат: перерисовываются только те компоненты, чья конкретная сущность действительно изменилась. Полную реализацию можно посмотреть на GitHub.
Мягкие удаления: трюк с enumerable: false
Это, пожалуй, самая интересная часть паттерна.
Когда вы удаляете сущность, нельзя просто убрать её из стора — компоненты, которые ещё держат ссылку на этот ID, упадут с ошибкой. Можно добавить флаг deleted и фильтровать его везде, но тогда логика удаления протекает во все потребители. Вместо этого реестр использует малоизвестную возможность JavaScript — перечислимость свойств (property enumerability).
Когда свойство определено с enumerable: false, оно становится невидимым для Object.values(), Object.keys(), Object.entries() и оператора spread { ...obj }, но при этом остаётся доступным при прямом обращении по ключу:
const obj = Object.defineProperties({}, { 'task-1': { value: { id: 'task-1', title: 'Задача 1' }, enumerable: true, configurable: true, }, 'task-2': { value: { id: 'task-2', title: 'Задача 2' }, enumerable: false, configurable: true, }, }); Object.keys(obj); // ['task-1'] — task-2 невидима при итерации obj['task-2']; // { id: 'task-2', title: 'Задача 2' } — но доступна по ключу
Что это даёт на практике:
Компоненты-списки (использующие
Object.values(state.task)) автоматически перестают видеть удалённую сущность.Компоненты-детали (обращающиеся по
state.task[taskId]) продолжают получать данные и не падают.Потребительский код не знает о механизме удаления вообще — удалённые сущности просто исчезают из перечисления.
Для инициирования мягкого удаления сервер отправляет минимальный объект:
{ "id": "task-1", "entityType": "task", "__isDeleted": true }
Внутри parse это обрабатывается одной строкой: enumerable: !('__isDeleted' in entity). Всё.
Никаких фильтров в компонентах, никаких дополнительных проверок — дескриптор свойства делает всю работу за нас. Если вы когда-нибудь задавались вопросом, зачем в JavaScript существуют Object.defineProperty и Object.defineProperties за пределами написания библиотек — вот вам практический кейс.
Использование реестра в компонентах
Чтение одной сущности по ID:
const UserProfile = ({ userId }: { userId: UserId }) => { const user = useRegistry(useShallow((state) => state.user[userId])); return <div>{user.fullName}</div>; };
Получение списка всех сущностей определённого типа:
const UserList = () => { const users = useRegistry( useShallow((state) => Object.values(state.user)) ); return ( <ul> {users.map((u) => ( <li key={u.id}>{u.fullName}</li> ))} </ul> ); };
Object.values перебирает только перечислимые свойства — мягко удалённые сущности (enumerable: false) автоматически исключаются, никаких дополнительных фильтров не нужно.
Когда компонент получает список ID от родителя, нужно учитывать мягкие удаления явно. Spread по словарю создаёт снимок только из перечислимых (не удалённых) записей, а id in проверяет наличие именно в этом снимке:
const UserList = ({ userIds }: { userIds: UserId[] }) => { const users = useRegistry( useShallow(({ user: { ...userReg } }) => userIds.filter((id) => id in userReg).map((id) => userReg[id])) ); return <ul>{users.map((u) => <li key={u.id}>{u.fullName}</li>)}</ul>; };
{ ...userReg } копирует только перечислимые свойства, поэтому удалённые ID в spread не попадут и будут отфильтрованы. Сама удалённая сущность при этом остаётся в сторе — просто невидима для перечисления.
Хук useShallow из zustand/shallow обеспечивает ре-рендер компонента только при реальном изменении выбранных данных, а не при каждом обновлении стора. В сочетании с deep-equal проверкой в parse это даёт минимально необходимое количество перерисовок.
Интеграция с HTTP-слоем
Паттерн раскрывается в полную силу, когда каждый ответ API автоматически проходит через parse. Достаточно встроить один вызов в ваш HTTP-слой:
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> { const res = await fetch(url, options); const data = await res.json(); useRegistry.getState().parse(data); return data; }
После этого вам не нужно вручную обновлять стор. Выполнили API-запрос — реестр сам извлёк и нормализовал все сущности из ответа, а подписанные компоненты обновились автоматически.
Это работает и со стриминговыми ответами. Если API возвращает данные инкрементально (например, через JSON Lines, SSE или WebSockets), вызывайте parse на каждый полученный чанк — реестр мержит новые данные в существующее состояние.
Предшественники и альтернативы
Идея нормализации состояния пришла из экосистемы Redux. Библиотека normalizr (автор — Paul Armstrong) ввела нормализацию на основе схем, а createEntityAdapter из Redux Toolkit предоставил встроенные CRUD-операции для нормализованного состояния.
Описанный здесь подход применяет тот же принцип — плоские словари, проиндексированные по ID, — но опирается на конвенцию (id + entityType) вместо описания схем. Это снижает порог входа и убирает boilerplate.
Паттерн не привязан к Zustand. Любая библиотека состояний с централизованным хранилищем и выборочными подписками подойдёт: Jotai, Valtio, или сам Redux.
Что ещё
В этой статье намеренно не затронута серверная предзагрузка данных (SSR). Вкратце: реестр можно предзаполнить на сервере через React Context, чтобы первый рендер приходил уже с данными, без спиннеров и мерцания пустых состояний. Подробности — в полной документации.
Часть серии Realtime UI
Эта статья — один из элементов архитектуры Realtime UI, потоково-ориентированного подхода к построению Next.js-приложений с помощью Vovk.ts. Полная архитектура охватывает нормализацию состояния (эта статья), real-time поллинг через Redis и JSON Lines, интеграцию с AI-чатами и голосовыми интерфейсами, а также поддержку MCP-серверов.
Автор разрабатывает Vovk.ts — open-source фреймворк для Next.js. Если паттерн показался полезным, буду рад звёздочке на GitHub и обратной связи в комментариях. Вопросы, критика, альтернативные подходы — всё приветствуется.
