Skip to Content
Realtime KanbanState Normalization with Zustand

Entity-driven state normalization with Zustand registry

Entity-driven state normalization suits apps with a lot of connected data. Instead of passing raw data to components or storing responses as they are, you extract the entities and store them in one central registry. This gives you a single source of truth, O(1) lookups by ID, and updates everywhere an entity is used. Components subscribe to entities by ID. When an entity changes, every component that uses it re-renders.

ACME Inc.
Welcome on board, {user.fullName}!
👤 {user.fullName}

Update your profile, {user.fullName}!

The demo above shows one user entity in four places: the header greeting, the sidebar, the page title, and the form input. Change the name and click Save: every component shows the change at once, because they all read the same entity from the registry.

How it works: a parse method takes each raw API response, extracts the entities recursively, normalizes them into flat dictionaries keyed by ID, and merges them into a Zustand store. A component re-renders only when its own data changes.

Entity Registry Pattern

Any component can read an item from the registry:

export const UserProfile = ({ userId }: { userId: User['id'] }) => { const userProfile = useRegistry(useShallow(state => state.user[userId])); return <div>{userProfile.fullName}</div> }

To get an array of entities, map over the IDs:

export const UserList = ({ userIds }: { userIds: User['id'][] }) => { const users = useRegistry( useShallow((state) => userIds.map((id) => state.user[id])), ); return <ul>{users.map((u) => <li key={u.id}>{u.fullName}</li>)}</ul> }

The implementation below also handles soft deletions: a deleted entity is marked non-enumerable but stays in the state. This prevents errors in components that still reference it.

export const UserList = ({ userIds }: { userIds: User['id'][] }) => { 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> }

Prior art and portability

Normalizing frontend state into flat entity maps comes from the Redux ecosystem. normalizr (first created by Dan Abramov) added declarative, schema-based normalization of nested JSON. Later, Redux Toolkit’s createEntityAdapter added built-in CRUD reducers and selectors for normalized state. This page uses the same idea, flat entity dictionaries keyed by ID, but relies on a convention (id + entityType) instead of schemas defined up front.

This article uses Zustand , but the pattern doesn’t depend on it. A state library needs three things: a central store of plain objects, a way to update the state and re-render only some components, and subscriptions, so a component re-renders only when its own entity changes. Any library with these can host the registry, for example Jotai  (each entity map could be an atom) or Valtio  (proxy-based reactivity gives automatic fine-grained subscriptions). The getEntitiesFromData function and the parse logic work with any library. Only the store creation and the subscription code change.

Defining entity types

The pattern relies on one rule: every entity in your data has an id field and an entityType field that says what kind of entity it is. In a real project, EntityType can come from @prisma/client, be generated from your schema, or be imported from wherever your source of truth lives. This page writes it out by hand:

src/types.ts
enum EntityType { user = 'user', task = 'task', } interface BaseEntity { id: string; entityType: EntityType; }

Entity IDs should be branded strings, not plain string types. Then you can’t pass a user ID where a task ID is expected, a common bug in apps with many entity types. With Zod, define a branded ID schema per entity:

import { z } from 'zod'; const UserIdSchema = z.string().brand<'user'>(); const TaskIdSchema = z.string().brand<'task'>(); type UserId = z.infer<typeof UserIdSchema>; // string & { __brand: 'user' } type TaskId = z.infer<typeof TaskIdSchema>; // string & { __brand: 'task' }

Without Zod, a TypeScript utility type does the same:

type BrandedId<T extends string> = string & { readonly __brand: T }; type UserId = BrandedId<'user'>; type TaskId = BrandedId<'task'>;

Either way, your entity interfaces use the branded type for id instead of string, so mixing up IDs of different entity types is a compile-time error.

In Realtime Kanban, EntityType comes from @prisma/client, and a Zod generator creates the entity types, branded IDs included. See the Database article.

Setting up the fetcher

First, set up the fetcher: the function that every generated RPC method uses to make HTTP requests. Its onSuccess event lets other code run on every successful response. The registry subscribes to this event to parse all incoming data.

src/lib/fetcher.ts
import { HttpStatus } from 'vovk'; import { createFetcher } from 'vovk/fetcher'; export const fetcher = createFetcher<{ bypassRegistry?: boolean }>({ onError: (error) => { if ( error.statusCode === HttpStatus.UNAUTHORIZED && typeof document !== 'undefined' ) { document.location.href = '/login'; } }, });

In the browser, the onError handler sends the user to the login page when authentication fails. The Telegram mixin calls the same fetcher on the server, where there is no document, so there the error is thrown as it is. The bypassRegistry type parameter adds a typed option that callers can pass, for example await UserRPC.getUsers({ bypassRegistry: true }), to skip the registry for one request when you only need the raw response.

Set the fetcher in the config, so the generated client imports it instead of the default one:

vovk.config.mjs
// @ts-check /** @type {import('vovk').VovkConfig} */ const config = { outputConfig: { imports: { // ... fetcher: './src/lib/fetcher.ts', }, }, }; export default config;

Implementing the registry

The registry is built on Zustand. A Registry interface describes the state: entity maps keyed by EntityType, and a parse method. The file exports a RegistryProvider, a useRegistry selector hook, and a useRegistryStore hook for imperative access.

src/hooks/use-registry.tsx
'use client'; import { EntityType } from '@prisma/client'; import type { TaskType } from '@schemas/models/Task.schema'; import type { UserType } from '@schemas/models/User.schema'; import fastDeepEqual from 'fast-deep-equal'; import { type ReactNode, createContext, useContext, useRef } from 'react'; import { type StoreApi, createStore, useStore } from 'zustand'; import { fetcher } from '@/lib/fetcher'; import type { BaseEntity } from '../types'; interface Registry { [EntityType.user]: Record<UserType['id'], UserType>; [EntityType.task]: Record<TaskType['id'], TaskType>; parse: (data: unknown) => void; } const MAX_DEPTH = 10; export function getEntitiesFromData( data: unknown, entities: Partial<{ [key in EntityType]: Record<BaseEntity['id'], BaseEntity>; }> = {}, depth = 0, ) { if (depth > MAX_DEPTH) 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'>>; } function createRegistryStore(initialData: { users?: UserType[]; tasks?: TaskType[]; }) { const initialEntities = getEntitiesFromData(initialData); return createStore<Registry>((set) => ({ [EntityType.user]: (initialEntities.user ?? {}) as Record< UserType['id'], UserType >, [EntityType.task]: (initialEntities.task ?? {}) as Record< TaskType['id'], TaskType >, 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; }); }, })); } const RegistryContext = createContext<StoreApi<Registry> | null>(null); export function RegistryProvider({ initialData, children, }: { initialData: { users?: UserType[]; tasks?: TaskType[] }; children: ReactNode; }) { const storeRef = useRef<StoreApi<Registry> | null>(null); if (!storeRef.current) { storeRef.current = createRegistryStore(initialData); const { parse } = storeRef.current.getState(); fetcher.onSuccess((data, { bypassRegistry }) => { if (bypassRegistry) return; if ( data && typeof data === 'object' && Symbol.asyncIterator in data && 'onIterate' in data && typeof data.onIterate === 'function' ) { data.onIterate(parse); } parse(data); }); } return ( <RegistryContext.Provider value={storeRef.current}> {children} </RegistryContext.Provider> ); } export function useRegistryStore() { const store = useContext(RegistryContext); if (!store) throw new Error('useRegistry must be used within RegistryProvider'); return store; } export function useRegistry<T>(selector: (state: Registry) => T): T { return useStore(useRegistryStore(), selector); }

The code above is fetched from GitHub repository. 

getEntitiesFromData function

getEntitiesFromData is a recursive function. It extracts entities from any data structure: every object with entityType and id properties. For each entityType it finds, it builds a record of the entity objects keyed by ID. Nested entities (like user inside task) are extracted with their parents in the same pass, so one call normalizes the whole response. Say the server returns this 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 the whole structure and returns a normalized shape. It doesn’t change 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" } } }

createRegistryStore factory

createRegistryStore takes the initial data (the arrays fetched during SSR) and returns a vanilla Zustand store that already holds it. It normalizes the arrays into entity maps with getEntitiesFromData and passes them as the store’s initial state:

function createRegistryStore(initialData: { users?: UserType[]; tasks?: TaskType[]; }) { const initialEntities = getEntitiesFromData(initialData); return createStore<Registry>((set) => ({ [EntityType.user]: (initialEntities.user ?? {}) as Record<UserType['id'], UserType>, [EntityType.task]: (initialEntities.task ?? {}) as Record<TaskType['id'], TaskType>, parse: (data) => { // ... }, })); }

The initial data goes inline to createStore, not through parse after the store is created. This matters for SSR. Zustand’s useStore hook relies on useSyncExternalStore, which calls getInitialState() during server-side rendering. getInitialState() returns the state from the moment the store was created. If you create the store empty and call parse afterward, the server render sees an empty state and returns empty HTML. With the data inline, getInitialState() returns the filled state, and SSR returns the right HTML from the start.

parse method

The parse method takes any data, extracts the entities, and stores them in the registry. Rather than adding the new entities to the state as they are, it reads the property descriptors of the existing entities with Object.getOwnPropertyDescriptors. If an entity already exists, parse compares it with the new one using the fast-deep-equal  library. If they are equal, the state doesn’t change. If not, parse creates a new property descriptor with the updated entity. This avoids re-rendering the components that use an unchanged entity.

Soft deletions via __isDeleted property

The __isDeleted property marks an entity as deleted. The entity stays in the state.

This works through JavaScript property descriptors. A property defined with enumerable: false is invisible to Object.values, Object.keys, and { ...spread }, but you can still read it by key. Code that iterates over entities doesn’t see deleted ones, and components that still hold the ID don’t crash:

const obj = Object.defineProperties({}, { 'task-1': { value: { id: 'task-1', title: 'Task 1' }, enumerable: true, configurable: true }, 'task-2': { value: { id: 'task-2', title: 'Task 2' }, enumerable: false, configurable: true }, }); Object.keys(obj); // ['task-1']: iteration doesn't see task-2 obj['task-2']; // { id: 'task-2', title: 'Task 2' }: still readable by ID

Descriptors are used instead of a separate deletedIds set or a filter on a boolean flag, because components then need no filter code: deleted entities drop out of enumeration by themselves.

When an entity arrives with __isDeleted, its property descriptor becomes non-enumerable. To soft-delete an entity, the server sends an object like this:

{ "id": "task-1", "entityType": "task", "__isDeleted": true }

RegistryProvider and SSR

RegistryProvider connects the Zustand store, the fetcher, and the React component tree. It creates the store once (with useRef), filled with the data fetched on the server. It also subscribes to the fetcher’s onSuccess event, so every later RPC response is parsed into the registry.

export function RegistryProvider({ initialData, children }) { const storeRef = useRef(null); if (!storeRef.current) { storeRef.current = createRegistryStore(initialData); const { parse } = storeRef.current.getState(); fetcher.onSuccess((data, { bypassRegistry }) => { if (bypassRegistry) return; if (/* data is an async iterable (JSON Lines stream) */) { data.onIterate(parse); } parse(data); }); } return ( <RegistryContext.Provider value={storeRef.current}> {children} </RegistryContext.Provider> ); }

The fetcher.onSuccess handler calls parse on regular JSON responses. For JSON Lines streaming responses (async iterables), it also registers parse as the onIterate callback, so each streamed item goes into the registry as it arrives. If the RPC call got the bypassRegistry option, the handler returns early and does nothing.

fetcher.onSuccess and fetcher.onError return a function that removes the callback. RegistryProvider usually wraps the whole app and never unmounts, so it doesn’t unsubscribe. Elsewhere, for example in a component that listens only under some condition, call the returned function to clean up.

The store is created and the onSuccess callback is registered synchronously, in the useRef initialization, not in useEffect. This is safe: useQuery and other client-side fetches start only after mount (in effects), so the handler is always registered before any response arrives.

useRegistry and useRegistryStore hooks

useRegistry reads the store from the context and applies a selector. Components get the same API as from a standard Zustand create hook:

export function useRegistry<T>(selector: (state: Registry) => T): T { return useStore(useRegistryStore(), selector); }

For imperative access outside selectors, for example to call parse from an effect, useRegistryStore returns the store itself:

const store = useRegistryStore(); store.getState().parse(data);

Using the registry with SSR

The server component fetches the data with the controller’s .fn() method, a direct server-side call without HTTP, and passes it to RegistryProvider. Child components select data from the store and need no initialData props:

src/app/page.tsx
export default async function Home() { const [users, tasks] = await Promise.all([ UserController.getUsers.fn<UserType[]>(), TaskController.getTasks.fn<TaskType[]>(), ]); return ( <RegistryProvider initialData={{ users, tasks }}> <AppHeader /> <UserList /> <UserKanban /> </RegistryProvider> ); }

Each component selects from the registry and runs a useQuery to refresh the data on the client:

src/components/user-list.tsx
const UserList = () => { const users = useRegistry(useShallow((state) => Object.values(state.user))); useQuery({ queryKey: UserRPC.getUsers.queryKey(), queryFn: () => UserRPC.getUsers(), }); return <ul>{users.map((u) => <li key={u.id}>{u.fullName}</li>)}</ul>; };

The data flow:

  1. SSR: the server fetches data with .fn(), RegistryProvider creates a store with that data inline, and the components render with it. The HTML sent to the client already holds the full UI.
  2. Hydration: React hydrates on the client. The store is created again with the same initial data, so the output is identical and there is no hydration mismatch.
  3. Client refresh: useQuery runs after mount and calls the RPC method through the fetcher. The onSuccess handler calls parse, the store updates, and the components re-render with fresh data.

That is two renders in total (SSR data, then fresh data), with no empty-state flicker.

Data from any source, whether useQuery, a JSON Lines stream, or an AI tool call, goes through the registry’s parse method, and every component that references it by ID updates.

Last updated on