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.
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.
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.');
});
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.
Related #
- Syncing Redux State with WebSocket Streams — normalized reducers in Redux.
- Fixing Zustand Stale WebSocket Subscriptions — selector pitfalls in the store used above.
- Handling Out-of-Order WebSocket Messages — the version guard in depth.
- Batching WebSocket Updates with requestAnimationFrame — applying many entity updates per frame.