Любое React-приложение, работающее со связанными данными — пользователи, задачи, комментарии, проекты — рано или поздно упирается в одну и ту же проблему: как поддерживать консистентность одной и той же сущности, отображаемой в десятке разных компонентов. Обновили имя пользователя в диалоге — а в шапке, сайдбаре, карточках задач и выпадающем списке по-прежнему старое значение. Начинаете пробрасывать пропсы, поднимать состояние, дублировать запросы. Это работает до определённого масштаба, а потом превращается в спагетти.

В этой статье я разберу паттерн Entity Registry — плоский реестр сущностей на базе Zustand, который автоматически нормализует любые ответы API, хранит данные в едином словаре по ID и обеспечивает точечный ре-рендер только тех компонентов, чьи данные действительно изменились. Отдельно разберём трюк с enumerable: false для мягких удалений — пожалуй, самую изящную часть паттерна.

Суть подхода

Вместо того чтобы хранить данные в том виде, в котором они пришли от сервера (вложенные объекты, массивы с дублями), мы извлекаем из ответа все сущности и складываем их в плоский словарь, где ключом является ID. Компоненты подписываются на конкретные сущности через селекторы Zustand и перерисовываются только при изменении своей части данных.

Entity Registry Pattern
Entity Registry Pattern

Контракт минимален: каждая сущность должна содержать поле 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, а затем мержит результат в стор. Для каждой входящей сущности он:

  1. Проверяет, есть ли сущность с таким ID в текущем состоянии.

  2. Выполняет глубокое сравнение старого и нового значения через fast-deep-equal. Если ничего не изменилось — обновление пропускается, ре-рендера не происходит.

  3. Записывает обновлённые сущности как дескрипторы свойств (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 и обратной связи в комментариях. Вопросы, критика, альтернативные подходы — всё приветствуется.