Normalizing Entities With normalizr
A single REST endpoint that returns a user with their posts, each post carrying its comments, hands you a tree three levels deep — and if the same author appears on two comments, you now hold two divergent copies of that author. normalizr solves this by flattening the tree into a store keyed by entity ID, and it is the concrete standalone tool behind Schema-Driven Normalization. Unlike a GraphQL client that normalizes implicitly, normalizr is a pure transform you invoke yourself, which pairs naturally with the manual reshaping covered in Flattening Deeply Nested GraphQL Responses when your transport is REST rather than GraphQL.
This page walks the full round trip: declare a schema.Entity per resource, normalize() the payload into { entities, result }, seed the flat entities into a React Query cache, and denormalize() back into a renderable tree — all while keeping IDs stable so downstream query keys stay valid. That ID stability is exactly the concern in Designing Stable Query Keys for React Query, and normalizr’s idAttribute is where the two meet.
Diagnostic Checklist
Confirm your symptoms match the normalizr use case before wiring it in:
- The same entity (an author, a tag, a category) is embedded in multiple places in one response and edits to one copy leave the others stale.
- Your reducer or mapper manually walks
user.posts[i].comments[j]to pull IDs out at each nesting level. - Two endpoints return the same resource in different shapes and you want one canonical record per ID.
JSON.stringify(response).lengthis large because full child objects are duplicated across parents rather than referenced by ID.- You need a flat store to feed React Query but you are on REST, so Apollo’s automatic normalization is not available.
Step-by-Step Implementation
Step 1 — Define schema.Entity for each nested resource
Declare the leaf entity first, then wire parents to children through the relations object. normalizr resolves each nested key against the schema you pass, so user.posts typed as [postSchema] tells it to extract an array of post entities.
// lib/schemas.ts
import { schema } from 'normalizr';
export interface Comment { id: string; body: string; authorId: string }
export interface Post { id: string; title: string; comments: Comment[] }
export interface User { id: string; name: string; posts: Post[] }
// Leaf entity — default idAttribute is 'id'
export const commentSchema = new schema.Entity<Comment>('comments');
// Post owns an array of comments
export const postSchema = new schema.Entity<Post>('posts', {
comments: [commentSchema],
});
// User owns an array of posts; the graph is now fully declared
export const userSchema = new schema.Entity<User>('users', {
posts: [postSchema],
});
Cache Behavior Analysis. Each schema.Entity records a name ('comments') that becomes the top-level key in the entities output, and a relations map that tells normalize which nested fields are themselves entities. The idAttribute defaults to id; normalizr reads entity.id to compute the store key, so every record must carry a stable, unique id or distinct entities collapse into one slot. Declaring comments: [commentSchema] (an array) versus author: commentSchema (a single) is what tells normalizr whether to produce an ID array or a scalar ID.
Step 2 — Call normalize() to get
normalize() walks the payload guided by userSchema and returns two things: entities, a dictionary grouped by schema name, and result, the top-level ID (or array of IDs) you use as the handle back into the graph.
import { normalize } from 'normalizr';
import { userSchema, User, Post, Comment } from './schemas';
interface NormalizedEntities {
users: Record<string, User>;
posts: Record<string, Post>;
comments: Record<string, Comment>;
}
export function normalizeUser(payload: User) {
const { entities, result } = normalize<User, NormalizedEntities, string>(payload, userSchema);
// entities.users → { '42': { id: '42', name: 'Ada', posts: ['7', '8'] } }
// entities.posts → { '7': { id: '7', title: '…', comments: ['9', '12'] }, '8': {…} }
// entities.comments → { '9': {…}, '12': {…}, '15': {…} }
// result → '42' (the top-level user id)
return { entities, result };
}
Cache Behavior Analysis. Inside normalize, each nested object is replaced by its computed ID and the full record is hoisted into entities[name]. Because normalizr deduplicates by key as it walks, an author embedded on ten comments produces exactly one entities.users entry — the ten references all become the same ID string. The result value mirrors the input shape: a single object yields a scalar ID, an array payload yields an ID array. Crucially, normalize is pure and stateless across calls; it knows nothing about what is already in your store, so cross-payload merging is your responsibility in the next step.
Step 3 — Store entities in a React Query cache (or Map)
Seed each entity type under its own query key so consumers read one canonical record and invalidation is per-type. A plain Map is the lighter alternative when the graph is ephemeral view state.
import { QueryClient } from '@tanstack/react-query';
import { normalizeUser } from './normalize-user';
import type { User } from './schemas';
const ENTITY_TYPES = ['users', 'posts', 'comments'] as const;
export function ingest(queryClient: QueryClient, payload: User) {
const { entities } = normalizeUser(payload);
for (const type of ENTITY_TYPES) {
queryClient.setQueryData<Record<string, unknown>>(['entities', type], (prev) => ({
...prev, // keep entities from earlier payloads
...entities[type], // later write wins on shared IDs (shallow)
}));
}
}
// Map alternative for derived, non-server view state:
export const userStore = new Map<string, User>();
export function ingestToMap(payload: User) {
const { entities } = normalizeUser(payload);
for (const [id, user] of Object.entries(entities.users)) {
userStore.set(id, user); // O(1) upsert, stable key = the entity id
}
}
Cache Behavior Analysis. Writing each type under ['entities', type] means queryClient.invalidateQueries({ queryKey: ['entities', 'comments'] }) refreshes only comments without disturbing users or posts. The shallow spread merges by ID: an entity present in both prev and the new payload keeps the new top-level fields, which is correct for full payloads but clobbers fields on partial ones — reach for a deep merge or a schema mergeStrategy when payloads are partial. Because the key is the entity’s own stable id, repeated ingests are idempotent on identity, so React Query’s structuralSharing can preserve references for entities whose fields did not actually change.
Step 4 — Denormalize for rendering, keeping IDs stable
At render-time, denormalize takes the result ID, the schema, and the flat entities, and reconstructs the nested tree — following each stored ID back to a full object.
import { denormalize } from 'normalizr';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { userSchema, User } from './schemas';
export function useUserTree(userId: string) {
const queryClient = useQueryClient();
// Subscribe to the three entity slices this tree reads
const { data: users } = useQuery({ queryKey: ['entities', 'users'], enabled: false });
const { data: posts } = useQuery({ queryKey: ['entities', 'posts'], enabled: false });
const { data: comments } = useQuery({ queryKey: ['entities', 'comments'], enabled: false });
return useMemo<User | undefined>(() => {
const entities = {
users: queryClient.getQueryData(['entities', 'users']),
posts: queryClient.getQueryData(['entities', 'posts']),
comments: queryClient.getQueryData(['entities', 'comments']),
};
if (!entities.users) return undefined;
// result = the stable user id; schema drives the rebuild
return denormalize(userId, userSchema, entities) as User;
}, [queryClient, userId, users, posts, comments]);
}
Cache Behavior Analysis. denormalize runs the schema in reverse: starting from userId, it looks up entities.users[userId], then follows the stored posts ID array into entities.posts, then each post’s comments IDs into entities.comments. It only touches IDs reachable from result, so an unrelated comment update elsewhere in the store does not force this tree to rebuild — but any change to a reachable entity does, which is why the useMemo depends on the three slices. The rebuilt tree is a fresh allocation each run, so memoizing on the stable userId plus the entity slices prevents denormalizing on every render.
Edge Cases and Gotchas
Non-id identity with idAttribute
When your resource is keyed by a slug or uuid field rather than id, set idAttribute explicitly or normalizr reads the missing id, resolves undefined, and collapses every record of that type into one slot:
const tagSchema = new schema.Entity<{ slug: string; label: string }>(
'tags',
{},
{ idAttribute: 'slug' }, // key on slug, not id
);
idAttribute also accepts a function (value, parent, key) => string for composite keys derived from multiple fields.
Circular references between entities
If a comment references its author and the author’s comments array references back, the graph is cyclic. normalize handles this because it keys entities as it walks — the second time it reaches an already-seen entity it emits the ID, not the object. denormalize guards the reverse: it will not infinitely re-enter an entity already on the current path.
Partial payloads overwriting full ones
A list endpoint returning { id, name } and a detail endpoint returning { id, name, bio } write to the same slot. A shallow spread lets the list payload erase bio. Use a per-entity mergeStrategy to preserve existing fields:
const userSchema = new schema.Entity<User>(
'users',
{ posts: [postSchema] },
{ mergeStrategy: (a, b) => ({ ...a, ...b }) }, // keep prior fields, overlay new
);
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
An entire entity type is stored under the key undefined |
The resource is not keyed by id but no idAttribute was set, so normalize reads entity.id and gets undefined |
Pass { idAttribute: 'slug' } (or a function) as the third schema.Entity argument so each record resolves a distinct key |
A detail field like bio disappears after a list refetch |
A partial list payload shallow-overwrote the fuller detail record in the store | Add a mergeStrategy to the entity schema, or deep-merge in setQueryData instead of spreading top-level only |
denormalize returns undefined for a nested child |
The child ID is present in the parent’s ID array but the child entity slice was never seeded (under-fetch) | Ingest all entity types from every payload; guard the render with a placeholder and trigger a targeted refetch for the missing ID |
| The tree re-renders on every render even when data is unchanged | denormalize is called inline without memoization, allocating a fresh tree each time |
Wrap denormalize in useMemo keyed on the stable result id plus the entity slices it reads |
Frequently Asked Questions
How does normalizr handle circular references between entities?
normalize() tracks entities by their computed key as it walks the tree, so a cycle — a post referencing an author who references the same post — resolves to IDs on both sides without infinite recursion. denormalize() reconstructs the cycle lazily and normalizr guards against re-entering an entity already on the current denormalization path, returning the reference rather than looping forever. You still need every entity in the cycle to carry a stable idAttribute value, or the dedup keying breaks down.
What happens if two payloads share an entity ID but carry different fields?
normalize() itself does not merge across separate calls — it returns the entities for the payload you passed. The merge happens where you write into your store. If you shallow-spread the new entities over the old, the later write wins field-by-field only at the top level, so a partial payload can clobber fields the earlier fuller payload had. Use a deep merge in setQueryData or a normalizr mergeStrategy on the schema when payloads are partial.
Should I keep the normalizr entities in React Query or a plain Map?
Use React Query when the entities must participate in staleTime, gcTime, and invalidation alongside your other server state — store each entity type under its own queryKey so per-type invalidation works. Use a plain Map when the normalized graph is derived, ephemeral view state that never triggers a fetch, since a Map avoids the cache bookkeeping and the extra re-render subscriptions while still giving O(1) upserts keyed on the stable entity id.
Related
- Schema-Driven Normalization — the parent reference comparing normalizr against Apollo
typePolicies, RTK QuerycreateEntityAdapter, and codegen-typed entities, and where to draw the schema-vs-mapper line. - Flattening Deeply Nested GraphQL Responses — the complementary manual flattening technique for GraphQL payloads, useful when you reshape fields before handing them to
normalize. - Designing Stable Query Keys for React Query — how the
idAttributeyou pick becomes the stable query key that keeps entity slices addressable across refetches. - Data Normalization & Query Key Design — the foundational section on entity mapping and cache topology that every normalizr schema decision builds on.