Setting up the Entity Registry
As discussed in the App Overview, the application state is going to be normalized and store entities in a dictionary by their IDs. The registry is going to have a parse method that accepts any data and extracts entities from it, storing them in the registry. The method is going to be used to process all incoming data from the server, so that all components that use this data are going to be updated automatically.
For additional type safety, letโs create a BaseEntity interface that describes the base fields of all entities in the database for additional type checking.
import { EntityType } from '@prisma/client';
export interface BaseEntity {
id: string;
createdAt: string | Date;
updatedAt: string | Date;
entityType: EntityType;
}import type { UserType } from "@schemas/models/User.schema";
import type { TaskType } from "@schemas/models/Task.schema";
import type { BaseEntity } from "./types";
import { EntityType } from "@prisma/client";
import { create } from "zustand";
import fastDeepEqual from "fast-deep-equal";
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">>;
}
export 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;
});
},
}));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. It returns, for each discovered entityType, a record (object) whose keys are entity IDs and whose values are the entity objects themselves (not arrays). 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" }
}
}useRegistry explained
First two bits such as [EntityType.user]: {} and [EntityType.task]: {} are the objects where the entities are going to be stored, with entity IDs as keys, represented by Record<UserType["id"], UserType> and Record<TaskType["id"], TaskType> types.
The third bit is the parse method that accepts any data, extracts entities from it and stores them in the registry. Thatโs where the magic happens. Instead of simply extending the state with new entities, weโre using Object.getOwnPropertyDescriptors to get the property descriptors of the existing entities. This way we can check if the entity already exists in the state and if it does, we can compare it with the new entity using fast-deep-equal library. If the entities are equal, we donโt update the state, otherwise we create a new property descriptor with the updated entity. This way we can avoid unnecessary re-renders of the components that use this entity.
Soft deletions
The __isDeleted property is used to mark entities as deleted without actually removing them from the state, avoiding errors in components that might still reference them. Once __isDeleted is received as part of the entity, the property descriptor is marked as non-enumerable, so it wonโt be included in Object.values or Object.keys calls, making the entity effectively invisible to the components.
In other words, in order to soft-delete an entity, the server needs to send an object like this:
{
"id": "task-1",
"entityType": "task",
"__isDeleted": true
}When the parse method processes this object, it marks the corresponding entity in the registry as non-enumerable, effectively hiding it from the UI without removing it from the state.
Tip: In order to be sure that the UI doesnโt display deleted entities, even if the userIds array contains them, you can add additional filtering in the components, for example:
const users = useRegistry(
useShallow(({ user: { ...userReg } }) => userIds.filter((id) => id in userReg).map((id) => userReg[id]))
);The destructuring will make sure that non-enumerable properties are not included in the resulting object.
Thatโs it. Each time when the data is received from from any source, it needs to go thru the parse method of the registry, and all components that use the data are going to be updated automatically.
import { useRegistry } from "@/registry";
const resp = await fetch('/api/some-endpoint');
const data = await resp.json();
useRegistry.getState().parse(data);In order to make it automatic, you can create a custom fetcher that calls the parse method for each response, as described in the next article.