Entity-driven state normalization with Zustand registry
Entity-driven state normalization is the most efficient way to manage application state in complex apps that deal with a lot of interconnected data. Instead of passing raw data to components or storing responses as-is in state, it’s better to extract entities from the data and store them in a centralized registry. Components can then subscribe to specific entities by their IDs; when an entity is updated, all components that use it are updated automatically.
Update your profile, John Doe!
An item in the registry can be used in any component as follows:
export const UserProfile = ({ userId }: { userId: User['id'] }) => {
const userProfile = useRegistry(useShallow(state => state.user[userId]));
return <div>{userProfile.fullName}</div>
}Arrays of entities can be derived by mapping over the IDs:
// ...
const users = useRegistry(
useShallow((state) => userIds.map((id) => state.user[id])),
);The implementation explained below also considers soft deletions, where deleted entities are marked as non-enumerable without actually removing them from the state. This prevents errors in components that might still reference them.
const users = useRegistry(
useShallow(({ user: { ...userReg } }) => userIds.filter((id) => id in userReg).map((id) => userReg[id]))
);Implementing useRegistry hook
The application state is normalized and stores entities in a dictionary by their IDs. The registry exposes a parse method that accepts any data and extracts entities from it, storing them in the registry. This method is used to process all incoming data from the server so that all components using this data are updated automatically.
useRegistry.getState().parse(data);For type safety, let’s create a BaseEntity interface that describes the common shape of all entities in the database. Each database entity will have id, createdAt, updatedAt, and entityType fields, where id and entityType are used to identify the entity in the registry.
import { EntityType } from '@prisma/client';
export interface BaseEntity {
id: string;
createdAt: string | Date;
updatedAt: string | Date;
entityType: EntityType;
}The useRegistry hook, built with the Zustand library, implements a Registry interface that describes the shape of the registry state: the parse method and entity maps such as user and task.
The types for UserType and TaskType are imported from generated schema files based on the Prisma models. For more details, see the Database article.
import { EntityType } from "@prisma/client";
import { create } from "zustand";
import fastDeepEqual from "fast-deep-equal";
import type { UserType } from "@schemas/models/User.schema";
import type { TaskType } from "@schemas/models/Task.schema";
import type { BaseEntity } from "../types";
interface Registry {
[EntityType.user]: Record<UserType["id"], UserType>;
[EntityType.task]: Record<TaskType["id"], TaskType>;
parse: (data: unknown) => void;
}
export function getEntitiesFromData(
data: unknown,
entities: Partial<{
[key in EntityType]: Record<BaseEntity["id"], BaseEntity>;
}> = {},
depth = 0,
) {
if (depth > 10) return entities as Partial<Omit<Registry, "parse">>;
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 = data.entityType as EntityType;
const id = (data as BaseEntity).id;
entities[entityType] ??= {};
entities[entityType][id] = data as BaseEntity;
}
}
return entities as Partial<Omit<Registry, "parse">>;
}
const useRegistry = create<Registry>((set, get) => ({
[EntityType.user]: {},
[EntityType.task]: {},
parse: (data) => {
const entities = getEntitiesFromData(data);
set((state) => {
const newState: Record<string, unknown> = {};
let isChanged = false;
Object.entries(entities).forEach(([entityType, entityMap]) => {
const type = entityType as EntityType;
const descriptors = Object.getOwnPropertyDescriptors(state[type] ?? {});
let areDescriptorsChanged = false;
Object.values(entityMap).forEach((entity) => {
const descriptorValue = descriptors[entity.id]?.value;
const value = { ...descriptorValue, ...entity };
const isCurrentChanged = !fastDeepEqual(descriptorValue, value);
descriptors[entity.id] = isCurrentChanged
? ({
value,
configurable: true,
writable: false,
enumerable: !("__isDeleted" in entity),
} satisfies PropertyDescriptor)
: descriptors[entity.id];
areDescriptorsChanged ||= isCurrentChanged;
});
newState[type] = areDescriptorsChanged
? Object.defineProperties({}, descriptors)
: state[type];
isChanged ||= areDescriptorsChanged;
});
return isChanged ? { ...state, ...newState } : state;
});
},
}));
export default useRegistry;The code above is fetched from GitHub repository.
getEntitiesFromData function explained
getEntitiesFromData is a recursive function that extracts entities from any data structure based on the presence of entityType and id properties. For each discovered entityType, it builds a record whose keys are entity IDs and whose values are the entity objects themselves. Let’s say the server returns the following response:
{
"tasks": [
{
"id": "task-1",
"title": "Task 1",
"entityType": "task",
"user": {
"id": "user-1",
"fullName": "John Doe",
"entityType": "user"
}
},
{
"id": "task-2",
"title": "Task 2",
"entityType": "task",
"user": {
"id": "user-2",
"fullName": "Jane Doe",
"entityType": "user"
}
}
]
}The function walks through the entire structure and produces a normalized intermediate shape (without mutating the original objects):
{
"task": {
"task-1": { "id": "task-1", "title": "Task 1", "entityType": "task", "user": { /* unchanged */ } },
"task-2": { "id": "task-2", "title": "Task 2", "entityType": "task", "user": { /* unchanged */ } }
},
"user": {
"user-1": { "id": "user-1", "fullName": "John Doe", "entityType": "user" },
"user-2": { "id": "user-2", "fullName": "Jane Doe", "entityType": "user" }
}
}parse method explained
The parse method accepts any data, extracts entities from it, and stores them in the registry. Instead of simply extending the state with new entities, it uses Object.getOwnPropertyDescriptors to get the property descriptors of the existing entities. This allows us to check whether an entity already exists in state and, if it does, compare it with the new entity using the fast-deep-equal library. If the entities are equal, we don’t update state; otherwise, we create a new property descriptor with the updated entity. This helps avoid unnecessary re-renders of components that consume this entity.
Soft deletions via __isDeleted property
The __isDeleted property is used to mark entities as deleted without actually removing them from the state, which avoids errors in components that might still reference them. Once __isDeleted is received as part of an entity, the property descriptor is marked as non-enumerable, so it won’t be included in Object.values or Object.keys calls or in { ...spread } operations, making the entity effectively invisible to most consumers.
In other words, to soft-delete an entity, the server needs to send an object like this:
{
"id": "task-1",
"entityType": "task",
"__isDeleted": true
}That’s it. Each time data is received from any source, it should go through the registry’s parse method, and all components that reference that data by ID are updated automatically.
import { useRegistry } from "@/registry";
const resp = await fetch('/api/some-endpoint');
const data = await resp.json();
useRegistry.getState().parse(data);To make this automatic, we’ll create a custom fetcher that calls the parse method for each response, as described in the next article.