Schema-Driven Normalization
Every normalization strategy eventually forces a choice: encode the shape of your data as an explicit, declarative schema that a library reads, or hand-write imperative mapping functions that walk each payload by hand. Schema-driven normalization commits to the former — you declare that a User owns many Post records and each Post owns many Comment records once, and the library derives the flat entity store, the ID extraction, and the relationship wiring from that single declaration. This is the natural next step after the flattening mechanics in Data Normalization & Query Key Design, and it sits alongside Entity Mapping Strategies, which covers the manual mapper approach this page deliberately contrasts against.
The payoff is centralization: the idAttribute, the foreign-key edges, and the denormalization path all live in one place, so a backend field rename or a new nesting level touches one schema line instead of a dozen scattered .map() callbacks. This guide walks the four production implementations — the standalone normalizr library, Apollo’s __typename/id-driven InMemoryCache, RTK Query’s createEntityAdapter, and codegen-typed entities — and then draws the line where a schema stops being worth it and a hand-rolled mapper wins.
Diagnostic Checklist
You are looking at a schema-driven normalization problem if you observe any of the following:
- The same entity type is mapped by three or more different hand-written functions, and a field rename requires editing all of them.
- A nested payload arrives four levels deep and your reducer logic is unreadable, with manual ID extraction repeated at each level.
- Two queries return the same
Userbut your cache holds two divergent copies because normalization was applied inconsistently. - Apollo shows a
Cache data may be lostwarning, or entities land under keys likeROOT_QUERY.userinstead ofUser:42, indicatingkeyFields/dataIdFromObjectis not resolving an identity. - RTK Query state stores an array of full entity objects rather than an
{ ids, entities }adapter shape, so lookups are O(n). - TypeScript types for your normalized entities are hand-maintained and drift from the backend contract, surfacing as runtime
undefinedon renamed fields.
Prerequisites
Schema-driven normalization assumes you have already solved the mechanical flattening problem. Before wiring a schema, be comfortable with:
- Turning a nested tree into a flat dictionary — how references replace embedded objects and why a flat store is O(1) to update, covered in Nested Data Flattening Techniques.
- Choosing a stable entity ID — the
idAttributecontract and why a synthetic or composite key beats array index, covered in Entity Mapping Strategies. - React Query v5 cache primitives —
queryClient.setQueryData,getQueryData, andinvalidateQueries({ queryKey }), since a standalone schema library writes into that cache.
Implementation 1 — normalizr schema.Entity + denormalize
The normalizr library is the reference implementation of schema-driven normalization decoupled from any cache. You declare a schema.Entity per resource, normalize() a payload into { entities, result }, seed the flat entities into React Query, and denormalize() back into a tree at render-time. It is the right tool when your transport is REST and your cache is TanStack Query rather than a GraphQL client.
Steps:
- Declare a
schema.Entityfor each resource, passing nested schemas in the relations object so normalizr knowsuser.postsis an array ofpostentities. - Call
normalize(payload, userSchema)to flatten into{ entities, result }, whereentitiesis grouped by type andresultis the top-level ID index. - Merge each entity group into the React Query cache under a per-type query key so every consumer reads one canonical record.
- At render-time, call
denormalize(result, userSchema, entities)to rebuild the nested view without re-fetching.
import { schema, normalize, denormalize } from 'normalizr';
import { useQueryClient } from '@tanstack/react-query';
interface Comment { id: string; body: string; authorId: string }
interface Post { id: string; title: string; comments: Comment[] }
interface User { id: string; name: string; posts: Post[] }
// ── Declare the schema once ────────────────────────────────────────────────
const commentSchema = new schema.Entity<Comment>('comments');
const postSchema = new schema.Entity<Post>('posts', { comments: [commentSchema] });
const userSchema = new schema.Entity<User>('users', { posts: [postSchema] });
interface NormalizedEntities {
users?: Record<string, User>;
posts?: Record<string, Post>;
comments?: Record<string, Comment>;
}
// ── Normalize a payload into the flat store ────────────────────────────────
export function ingestUser(queryClient: ReturnType<typeof useQueryClient>, payload: User) {
const { entities, result } = normalize<User, NormalizedEntities>(payload, userSchema);
// Merge each entity type into its own cache slot — later data wins
for (const type of ['users', 'posts', 'comments'] as const) {
queryClient.setQueryData<Record<string, unknown>>(['entities', type], (prev) => ({
...prev,
...entities[type],
}));
}
// `result` is the top-level user ID; store it as the list handle
queryClient.setQueryData<string>(['userResult', payload.id], result as string);
}
// ── Rehydrate the tree for rendering ───────────────────────────────────────
export function selectUserTree(entities: NormalizedEntities, userId: string): User {
return denormalize(userId, userSchema, entities) as User;
}
Cache Behavior Impact. normalize() walks the payload guided by the schema, replaces each nested object with its idAttribute value (default id), and groups the extracted records under entities[type]. Because every User:42 collapses to one dictionary slot regardless of how many posts embed it, the spread merge in setQueryData is idempotent — writing the same entity twice is a no-op on identity. denormalize() runs the schema in reverse, following the stored IDs back to full objects; it is lazy in the sense that it only touches the IDs reachable from result, so an unrelated entity update does not force a re-denormalize of this tree.
Configuration Trade-offs:
- Storing each entity type under its own
queryKey(['entities', 'posts']) letsinvalidateQueries({ queryKey: ['entities', 'posts'] })refresh one slice without evicting users or comments. - normalizr’s
idAttributemust be set per entity when the key is not literallyid; aslug-keyed entity that defaults toidsilently stores everything underundefined, collapsing the whole type into one record. denormalizeallocates a fresh object tree on each call, so wrap it inuseMemokeyed on[userId, entities]— otherwise React Query’sstructuralSharingcannot help because you are rebuilding references downstream of the cache.- normalizr does no reference counting, so orphaned entities linger; pair a periodic sweep with React Query
gcTimeon the per-type keys if memory matters.
Implementation 2 — Apollo __typename/id via typePolicies
Apollo Client v3 performs schema-driven normalization implicitly: every GraphQL object that carries a __typename and an identity field is collapsed into a __typename:id store entry. The “schema” here is your GraphQL type system plus the typePolicies you register on InMemoryCache. You rarely call a normalize function yourself — you configure identity with keyFields (the modern replacement for dataIdFromObject) and let the cache flatten every query result automatically.
Steps:
- Request
__typenameand the identity field in every query (Apollo injects__typenameautomatically) so the cache can compute a key. - Register a
typePolicyper entity, overridingkeyFieldsonly when identity is a non-idor composite key. - Read entities back through
useQuery; Apollo denormalizes from the flat store transparently at read-time. - Mutate a single entity with
cache.modifyor a mutationupdateand every list holding that reference re-renders from the one canonical record.
import { InMemoryCache, gql, useQuery } from '@apollo/client';
export const cache = new InMemoryCache({
typePolicies: {
// Default identity is __typename + id; override only when it differs
User: { keyFields: ['id'] },
Post: { keyFields: ['id'] },
// Comment identity is a composite (thread + sequence), not a bare id
Comment: { keyFields: ['threadId', 'seq'] },
},
});
const USER_WITH_POSTS = gql`
query UserWithPosts($id: ID!) {
user(id: $id) {
id
name
posts {
id
title
comments { threadId seq body }
}
}
}
`;
interface UserData {
user: { id: string; name: string; posts: Array<{ id: string; title: string }> };
}
export function useUserWithPosts(id: string) {
return useQuery<UserData, { id: string }>(USER_WITH_POSTS, {
variables: { id },
fetchPolicy: 'cache-first',
});
}
Cache Behavior Impact. On write, InMemoryCache walks the response, computes each object’s key from its typePolicy keyFields (falling back to the legacy dataIdFromObject default of __typename:id), and stores it flat under that key while replacing the nested object with a Reference. Because Comment declares a composite keyFields, two comments that happen to share a numeric seq in different threads stay distinct. On read, Apollo denormalizes by following those Reference pointers, so a cache.modify on User:42 propagates to every query whose result includes that reference without any manual list patching.
Configuration Trade-offs:
keyFieldsis the load-bearing knob: omit it and Apollo uses__typename:id; a type without anidand withoutkeyFieldsfalls back to being embedded in its parent (non-normalized), which silently breaks cross-query sharing.- Set
keyFields: falsedeliberately for value-object types (e.g. aMoney{ amount, currency }) that should be embedded, not normalized. dataIdFromObjectstill works as a global fallback but is deprecated in favor of per-typekeyFields; preferkeyFieldsso identity is co-located with the type.- Pair
keyArgson list fields with these entity policies — identity normalizes the items,keyArgscontrols how the list itself is cached, and the two are configured independently.
Implementation 3 — RTK Query createEntityAdapter
RTK Query pushes normalization into a Redux slice via createEntityAdapter, which maintains the canonical { ids: EntityId[]; entities: Record<EntityId, T> } shape and generates memoized selectors. The “schema” is the adapter configuration — selectId (the identity extractor) and sortComparer. It is the right fit when you already run Redux Toolkit and want normalization plus generated CRUD reducers in one call.
Steps:
- Create an adapter with
selectIdpointing at your entity’s identity field and an optionalsortComparerfor stable ordering. - Transform the raw API response in RTK Query’s
transformResponseand seed it withadapter.setAll. - Expose the adapter’s generated selectors (
selectById,selectAll) throughgetSelectorsso components read O(1). - Apply granular updates with
adapter.updateOne/upsertManyinsideupdateQueryDatafor optimistic mutations.
import { createEntityAdapter, EntityState } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Post { id: string; title: string; score: number }
const postsAdapter = createEntityAdapter<Post>({
selectId: (post) => post.id,
sortComparer: (a, b) => b.score - a.score, // highest score first
});
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (build) => ({
getPosts: build.query<EntityState<Post, string>, void>({
query: () => 'posts',
// Flatten the raw array into the normalized adapter shape
transformResponse: (raw: Post[]) => postsAdapter.setAll(postsAdapter.getInitialState(), raw),
providesTags: (result) =>
result
? [...result.ids.map((id) => ({ type: 'Post' as const, id })), { type: 'Post', id: 'LIST' }]
: [{ type: 'Post', id: 'LIST' }],
}),
}),
});
// Generated O(1) selectors over the normalized state
export const postSelectors = postsAdapter.getSelectors<EntityState<Post, string>>((s) => s);
Cache Behavior Impact. createEntityAdapter keeps entities in an { ids, entities } map, so setAll replaces the collection while preserving object references for unchanged rows — the generated selectAll selector is memoized and only recomputes when ids or entities change identity. Because providesTags emits a tag per entity ID plus a LIST tag, an invalidatesTags: [{ type: 'Post', id: '5' }] on a mutation refetches only that entity’s dependents, while structural updates hit the LIST tag. The adapter’s sortComparer maintains ids order on every upsertOne, so inserts land in sorted position without a full re-sort of the array.
Configuration Trade-offs:
selectIdis the identity contract equivalent to normalizr’sidAttributeand Apollo’skeyFields; a wrongselectId(e.g. a non-unique field) silently collapses distinct entities into one slot.sortComparerruns on every write; for large collections with frequent inserts, omit it and sort in a selector instead to avoid O(n log n) on each mutation.- Use
updateOneinsideapi.util.updateQueryDatafor optimistic patches so only the touched entity’s reference changes;setAllreplaces the whole collection and defeats referential stability. - The generated selectors are per-slice; compose them with
createSelectorwhen you need to join two normalized adapters rather than re-normalizing at the component.
Schema vs Hand-Rolled Mappers
A schema is not free: it adds a dependency, a declaration to keep in sync, and an indirection that obscures a trivial transform. Push normalization to a schema when identity and relationships are the hard part; keep a hand-rolled mapper when the shape is the hard part.
Choose a schema (normalizr, Apollo typePolicies, createEntityAdapter) when: the same entity type recurs under multiple parents, nesting exceeds two levels, the same records must be shared across many queries, or you want a single idAttribute declaration that a rename touches once. Choose a hand-rolled mapper when: the response is flat and one-off, you are reshaping fields (renaming, computing derived values, unit conversion) rather than extracting identity, or the transform is used exactly once and a schema would be ceremony. The two also compose — a hand-rolled transformResponse that reshapes fields can feed its output straight into normalize() or adapter.setAll. When your entity types are generated by codegen (GraphQL Code Generator or an OpenAPI type generator), lean toward a schema: the compiler already guarantees the shape, so the schema only has to declare identity and edges, and the two together catch both a renamed field (TypeScript error) and a broken relationship (schema resolves undefined).
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
normalizr stores an entire type under the key undefined |
The entity’s real key is not id but no idAttribute was set, so normalizr reads payload.id and gets undefined |
Pass { idAttribute: 'slug' } (or a function) to the schema.Entity constructor so every record resolves a distinct key |
| Apollo holds two divergent copies of the same entity | Query omitted the identity field or __typename, so InMemoryCache cannot compute keyFields and embeds the object in its parent |
Add id (or the keyFields fields) and __typename to the selection set; verify the entry lands under User:42, not ROOT_QUERY.user |
| RTK Query lookups are O(n) and mutations re-render the whole list | Response stored as a raw array instead of the adapter shape, so selectById scans linearly |
Wrap the response in adapter.setAll(getInitialState(), raw) inside transformResponse and read via the generated selectById |
| Denormalized tree re-renders on every unrelated cache write | denormalize() (or an Apollo read) rebuilds a fresh object graph each call and it is not memoized |
Wrap denormalize in useMemo keyed on the specific result id and the entity slices it reads, not the whole entity dictionary |
| Codegen types compile but runtime entity is missing fields | Generated types match the schema but the query under-fetched, or keyFields/selectId points at a field the query did not request |
Align the selection set with the identity contract; make the identity field non-optional in the generated type so under-fetch is a compile error |
Frequently Asked Questions
When should I choose an explicit schema over hand-rolled mapping functions?
Reach for a schema (normalizr, Apollo typePolicies, or createEntityAdapter) once the same entity type appears under more than two parent shapes or nesting depth exceeds two levels. A schema centralizes the idAttribute and relationship wiring in one declaration, so a backend field rename touches one line. Hand-rolled mappers stay competitive only for one-off flat responses where the mapping is trivial and unlikely to be reused — and even then, a mapper that reshapes fields can feed its output into a schema, so the two are complementary rather than exclusive.
Does normalizr replace Apollo InMemoryCache or RTK Query normalization?
No — they occupy different layers. Apollo InMemoryCache and RTK Query’s createEntityAdapter normalize inside a caching library and are wired to its read/write lifecycle. normalizr is a standalone transform you run yourself before writing into React Query or a Map, giving you a flat store without adopting a full GraphQL client. Use normalizr when your transport is REST and your cache is React Query; use Apollo typePolicies when you are already on GraphQL; use createEntityAdapter when you are on Redux Toolkit.
How does Apollo derive the normalized cache key from __typename and id?
InMemoryCache calls keyFields on the type’s typePolicy (or the legacy dataIdFromObject) for every object carrying a __typename. By default it concatenates __typename and id into a key like User:42, and every occurrence of that object across queries collapses to that single store entry. Override keyFields on the typePolicy when your entity’s identity is a composite key (['threadId', 'seq']) or a non-id field such as a slug, and set keyFields: false for value objects you want embedded rather than normalized.
Can codegen guarantee my normalized entity types match the API?
Codegen (GraphQL Code Generator or an OpenAPI type generator) derives the entity interfaces from the server contract, so a schema change surfaces as a TypeScript error at build time rather than a runtime undefined. It does not verify that your idAttribute/keyFields/selectId exists or that relationships resolve — that wiring still lives in your normalizr schema, typePolicy, or adapter. Pair generated types with a schema so the compiler checks the shape and the schema checks the identity.
Related
- Data Normalization & Query Key Design — the parent section covering entity mapping, query key hierarchies, and the cache topology decisions that schema-driven normalization operationalizes.
- Normalizing Entities With normalizr — the step-by-step recipe for defining
schema.Entity, callingnormalize/denormalize, and seeding the flat store into React Query. - Entity Mapping Strategies — the hand-rolled mapper approach this page contrasts against, including how to design a stable entity ID contract before a schema consumes it.
- Nested Data Flattening Techniques — the mechanical flattening pass that turns a deep tree into references, the prerequisite skill a schema automates.
- Pagination Normalization Patterns — how the flat entity store produced by a schema plugs into paginated list traversal without duplicating entity payloads across pages.