Normalizing real-time entities in client state #

A user’s display name changes, and the WebSocket event updates it in the chat header — but the old name stays in the member list, the mention autocomplete, and three message bubbles. Another event marks a task done; the board column shows it done, the “My tasks” sidebar still shows it open. Each view keeps its own copy of the data it fetched, shaped for that view, and a real-time update only reaches whichever copy the handler thought to patch. The fix is not more careful handlers but a different state shape: normalized entities, stored once by type and id, with views holding only ids. Then one update to one entity reaches every view that shows it, automatically.

Root cause #

Data fetched for display tends to arrive denormalized: a message list embeds each author object, a board embeds its tasks, a search result embeds whatever it matched. Storing those responses as-is means the same entity lives in several places. Static pages get away with it, because data is refetched on navigation. Real-time apps do not: an update event names one entity, and the handler must find and patch every copy — an error-prone job that grows with each new view, and that usually fails silently when someone adds a view and forgets the handler.

Denormalized state also makes reconciliation harder. Version checks, which protect against the reordering described in handling out-of-order WebSocket messages, must be applied to every copy, and a copy that misses one update can later overwrite a newer copy when a view refetches.

One event, many copies A user-updated event patches the chat header, but the member list and message bubbles hold their own embedded copies that the handler did not update, so they keep showing the old name. One event, many copies WebSocket Chat header Member list Messages user.updated name=Ada L. patched (handler forgot this view) (embedded author copies) still shows old name With normalized state there is exactly one copy — and nothing to forget
Denormalized state turns every update into a scavenger hunt.

Resolution #

Keep one table per entity type, keyed by id, and make views hold arrays of ids (or queries that produce them). Normalize every incoming payload — HTTP responses and WebSocket events alike — through the same function, which splits nested objects into their tables and applies a version guard per entity. Components select an entity by id, so they re-render only when that entity changes.

import { create } from 'zustand';

interface User { id: string; name: string; avatarUrl: string; version: number }
interface Message { id: string; roomId: string; authorId: string; text: string; version: number }
interface Room { id: string; title: string; messageIds: string[]; memberIds: string[]; version: number }

interface Entities {
users: Record<string, User>;
messages: Record<string, Message>;
rooms: Record<string, Room>;
}

// Incoming payloads may embed authors; normalization splits them out.
type IncomingMessage = Omit<Message, 'authorId'> & { author: User };

function upsert<T extends { id: string; version: number }>(table: Record<string, T>, e: T): Record<string, T> {
const cur = table[e.id];
if (cur && cur.version >= e.version) return table; // stale or duplicate: keep table identity
return { ...table, [e.id]: e }; // new identity only when something changed
}

export const useEntities = create<Entities & { apply: (ev: ServerEvent) => void }>((set) => ({
users: {}, messages: {}, rooms: {},
apply: (ev) => set((s) => {
switch (ev.type) {
case 'message.created': {
const { author, ...rest } = ev.message;
const msg: Message = { ...rest, authorId: author.id };
const room = s.rooms[msg.roomId];
return {
users: upsert(s.users, author), // author stored once
messages: upsert(s.messages, msg),
rooms: room && !room.messageIds.includes(msg.id)
? { ...s.rooms, [room.id]: { ...room, messageIds: [...room.messageIds, msg.id] } }
: s.rooms,
};
}
case 'user.updated':
return { users: upsert(s.users, ev.user) }; // every view showing this user updates
case 'message.deleted': {
const { [ev.id]: _, ...messages } = s.messages;
const room = s.rooms[ev.roomId];
return {
messages,
rooms: room ? { ...s.rooms, [room.id]: { ...room, messageIds: room.messageIds.filter((m) => m !== ev.id) } } : s.rooms,
};
}
default:
return s;
}
}),
}));

type ServerEvent =
| { type: 'message.created'; message: IncomingMessage }
| { type: 'user.updated'; user: User }
| { type: 'message.deleted'; id: string; roomId: string };

// Components select by id: a bubble re-renders only when its message or its author changes.
export function useMessage(id: string) {
const message = useEntities((s) => s.messages[id]);
const author = useEntities((s) => (message ? s.users[message.authorId] : undefined));
return { message, author };
}

The version-guarded upsert returns the same table object when nothing changed, which is what lets selector-based subscriptions skip re-renders — the same identity rule described in React WebSocket state with useSyncExternalStore. Libraries can do the splitting for you: Redux Toolkit’s createEntityAdapter provides normalized tables with CRUD reducers, and normalizr converts nested payloads to tables from a schema. The principle is the same with any store: one copy per entity, ids everywhere else.

HTTP responses must go through the same normalization. If an initial page load stores a nested response as-is while socket events are normalized, the two representations drift. Normalize on the way in, whatever the source, and apply the same version guard, so a slow HTTP response cannot overwrite a newer socket update — the race also discussed in updating TanStack Query cache from WebSockets.

Places each update must reach In a denormalized store, a user rename must patch five copies, a task completion three and a message edit two; in a normalized store each update patches exactly one entity. Places each update must reach typical chat and project views copies to patch (denormalized) copies to patch (normalized) 0 2 4 6 5 1 user renamed 3 1 task completed 2 1 message edited
Normalization turns N patch sites into one — and removes the ones nobody remembers.

Edge cases #

Garbage collection. Entities accumulate as users browse. Periodically drop entities no view references (for example, messages from rooms closed long ago), or cap tables with a least-recently-used policy, keeping referenced ids.

Partial entities. Events may carry only some fields of an entity (a presence event with just id and online). Merge partial updates into the existing entity rather than replacing it, and keep the version semantics consistent — partial updates should carry the version they were made at.

Ordering within lists. Id lists per view (a room’s messages, a column’s tasks) have their own ordering rules. Keep order in the list, not in the entities, and let list-changing events (insert, move, delete) update the list with the same version discipline.

Verification #

Write a test that feeds the store a mix of HTTP-shaped nested payloads and socket events in shuffled order, then asserts: each entity exists exactly once, every view’s id list resolves to entities, and the final entity versions are the highest seen. In the browser, rename a user from another client and confirm every place the name appears updates simultaneously, with the React DevTools Profiler showing re-renders only in components that display that user.

it('one rename updates every view', () => {
const { apply } = useEntities.getState();
apply({ type: 'message.created', message: { id: 'm1', roomId: 'r1', text: 'hi', version: 1, author: { id: 'u1', name: 'Ada', avatarUrl: '', version: 1 } } });
apply({ type: 'user.updated', user: { id: 'u1', name: 'Ada L.', avatarUrl: '', version: 2 } });
apply({ type: 'user.updated', user: { id: 'u1', name: 'Ada', avatarUrl: '', version: 1 } }); // stale replay
expect(useEntities.getState().users.u1.name).toBe('Ada L.');
});
Normalized client state Normalized client state keeps one versioned record per entity in tables, ordered id lists per view, a single normalizer for all incoming data, id-based selectors and components that never copy entities into local state. Normalized client state Entity tables users, messages, rooms, tasks — one record per id, versioned single copy View id lists room.messageIds, column.taskIds — order lives here references Normalizer HTTP responses and socket events enter through one function one door Selectors select by id; re-render only on that entity's change fine-grained Components never hold copies of entities in local state render only The normalizer is the only place that knows payload shapes
One copy, many views, one door in.

Operational checklist #

FAQ #

Why normalize state in a real-time app? #

Because each real-time event names one entity, and normalized state stores each entity once. The update reaches every view that shows it, with no per-view patching logic to forget.

Does normalization work with TanStack Query? #

TanStack Query caches per query, which is inherently denormalized. Either patch every affected query on each event, or keep a normalized store for live entities and use queries mainly for loading. Some teams combine both, normalizing entities out of query results.

How do I keep lists sorted when entities change? #

Keep ordering in the view’s id list and update it with list events (insert, move, delete). If order depends on an entity field (for example, last activity), derive the sorted list with a memoized selector over the ids and entities.

Isn’t copying objects on every update expensive? #

Replacing one entity and one table object per update is cheap; the expensive part is re-rendering, which normalization reduces by letting components subscribe to exactly the entities they show. For very high update rates, batch updates per animation frame.

Back to WebSocket State Sync and Optimistic Updates.