Denormalizing Cache Data for Rendering
Normalization flattens your server responses into a dictionary of entities keyed by ID, which is exactly what you want for write consistency and cheap invalidation. But components do not render dictionaries — they render trees. A post card needs its author object inlined, a thread needs its comments and each comment’s user attached. Denormalization is the reverse trip: reassembling those flat records into the nested, render-ready shape a component consumes. This page is a concrete recipe within Nested Data Flattening Techniques, and it is the mirror image of flattening deeply nested GraphQL responses — where that page tears a tree apart, this one rebuilds it. Done naively, denormalization re-runs on every render and issues one store lookup per relationship, so a 50-item list with authors becomes 50+ lookups per frame. The fix is memoized selector and derivation patterns that recompute only when their inputs change.
The mental model: normalized storage is the source of truth, and denormalization is a derived view. Never persist the denormalized tree back into the cache — that reintroduces the duplication normalization exists to remove.
Cache Behavior Analysis Checklist
Before optimizing, confirm the symptom is denormalization cost and not something else:
- React DevTools Profiler shows a list component re-rendering when an unrelated entity changed.
- A flame chart shows repeated
Map.get/property access inside a selector proportional toitems × relationships. - The same author object is re-created (new reference) on every render even though its fields never changed.
- Memoization is present but keyed on the whole store object, so any write busts the cache.
Step-by-Step Implementation
Step 1 — Define the render-ready shape
Start from the component, not the store. Write the exact denormalized type your JSX consumes. This becomes the contract the selector must satisfy and keeps you from over-fetching relationships the view never touches.
// types/feed.ts
export interface User {
id: string;
name: string;
avatarUrl: string;
}
export interface Post {
id: string;
title: string;
authorId: string; // reference held in normalized store
commentIds: string[];
}
// The shape the component actually renders
export interface DenormalizedPost {
id: string;
title: string;
author: User; // inlined
commentCount: number; // derived, not stored
}
Cache Behavior Analysis. Declaring DenormalizedPost separately from Post keeps the stored entity flat (authorId, commentIds) while the view type is fully resolved. Because commentCount is derived from commentIds.length at read time, it can never disagree with the underlying list — there is no second copy to keep in sync.
Step 2 — Write a memoized denormalize selector
The heart of the pattern. Use reselect (or useMemo) so the reassembly only runs when the specific slices it reads change, not on every render.
// selectors/denormalizePosts.ts
import { createSelector } from 'reselect';
import type { Post, User, DenormalizedPost } from '../types/feed';
interface EntityState {
posts: { ids: string[]; entities: Record<string, Post> };
users: Record<string, User>;
}
const selectPostEntities = (s: EntityState) => s.posts.entities;
const selectPostIds = (s: EntityState) => s.posts.ids;
const selectUsers = (s: EntityState) => s.users;
export const selectDenormalizedPosts = createSelector(
[selectPostIds, selectPostEntities, selectUsers],
(ids, posts, users): DenormalizedPost[] =>
ids.map((id) => {
const post = posts[id];
return {
id: post.id,
title: post.title,
author: users[post.authorId], // single O(1) lookup per post
commentCount: post.commentIds.length,
};
}),
);
Cache Behavior Analysis. createSelector memoizes on the reference identity of its three inputs. If a write touches an unrelated slice — say a settings flag — selectPostIds, selectPostEntities, and selectUsers all return their previous references, so the mapper never runs and the same array is returned by reference. Downstream React.memo components then bail out of re-rendering. The memoization key is deliberately the entity maps, not the root state.
Step 3 — Batch lookups to avoid N+1 access
A naive selector that calls a second selector per item re-subscribes once per row, which is the N+1 pattern in disguise. Resolve all relationships against maps you read once, outside the loop.
// Anti-pattern: one selector invocation per row (N+1)
// posts.map(p => selectUserById(state, p.authorId)); // reads store N times
// Correct: read the users map once, index into it in the loop
export const selectThreads = createSelector(
[selectPostIds, selectPostEntities, selectUsers, (s: EntityState) => s.users],
(ids, posts, usersA, usersB) => {
const users = usersA; // single reference captured once
return ids.map((id) => {
const post = posts[id];
const author = users[post.authorId];
if (!author) {
// Missing relationship — render a placeholder, never throw mid-map
return { id: post.id, title: post.title, author: null, commentCount: 0 };
}
return { id: post.id, title: post.title, author, commentCount: post.commentIds.length };
});
},
);
Cache Behavior Analysis. Because the users map is captured once and indexed with bracket access, the cost is O(posts) lookups against an in-memory object — not O(posts) store subscriptions. The missing-author guard returns a placeholder instead of throwing, so a partially-loaded cache (author fetched later than its post) renders a skeleton row rather than crashing the whole list.
Step 4 — Use library-native readers
Both normalizr and Apollo ship denormalization primitives so you rarely hand-roll the graph walk for deeper trees.
// normalizr: rebuild the tree from { result, entities }
import { denormalize, schema } from 'normalizr';
const user = new schema.Entity('users');
const post = new schema.Entity('posts', { author: user });
const postList = new schema.Array(post);
// `result` is the list of ids; `entities` is the flat store from normalize()
const tree = denormalize(result, postList, entities);
// Apollo: read a single entity's fields from the store without a network op
import { gql } from '@apollo/client';
const AUTHOR_FRAGMENT = gql`
fragment AuthorCard on User { id name avatarUrl }
`;
const author = cache.readFragment({
id: cache.identify({ __typename: 'User', id: '9' }),
fragment: AUTHOR_FRAGMENT,
});
Cache Behavior Analysis. denormalize walks the postList schema and swaps each authorId for the resolved users record from entities; if the same user ID appears under multiple posts, normalizr hands back the same object reference, preserving structural sharing for free. Apollo’s readFragment resolves one Reference to its scalar fields synchronously from the InMemoryCache — no query, no network — which is exactly what you want inside a cache.modify or a component that already knows the entity’s id.
- Trade-offs:
createSelector’s default cache size is 1, so a selector reused with different arguments (different list filters) thrashes — usecreateSelectorCreatorwith a larger cache or a per-instance selector viauseMemo.denormalizerebuilds the whole subtree when any input entity changes; for very deep graphs, denormalize per-visible-item rather than the entire list.readFragmentreturnsnullon a partial cache, so always null-check before rendering.
Edge Cases and Gotchas
Dangling references after eviction
If an entity is evicted (Apollo cache.evict, or a manual delete from an entity map) but a parent still lists its ID, denormalization resolves the child to undefined. Always guard the lookup and render a placeholder, and prefer removing the ID from parent lists in the same update that evicts the child.
Memoization thrash from inline arguments
Passing a fresh object or array as a selector argument every render (selectThings(state, { ids: [...] })) defeats memoization because the argument reference changes each time. Hoist the argument, or stabilize it with useMemo, so the selector sees a stable reference.
Over-denormalizing beyond the viewport
Denormalizing a 5,000-item list eagerly builds 5,000 subtrees even though only ~20 are on screen. Pair the selector with a virtualized renderer and denormalize lazily per visible row, or slice the ID list before the map.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| List re-renders when an unrelated entity changes | Selector memoized on the whole store object, so any write busts the cache | Key createSelector on the specific entity maps (posts.entities, users) rather than root state |
| Profiler shows lookup cost scaling with items × relationships | N+1 pattern: a nested selector runs once per row instead of reading the map once | Capture the relationship map once outside the loop and index into it with bracket access |
Author renders as undefined and crashes the row |
Dangling reference — child evicted but parent still lists its ID | Null-check every resolved relationship and render a placeholder; remove the ID from parent lists in the same update as the eviction |
| Same denormalized array recomputes every render despite memoization | An inline object/array argument creates a new reference each render, defeating reselect | Hoist or useMemo the argument so the selector receives a stable reference |
Frequently Asked Questions
Why not just store the nested tree instead of denormalizing on read?
Storing the nested tree duplicates every entity that appears in more than one place — the same author embedded in ten posts is ten copies. An edit to one leaves the other nine stale, which is precisely the bug normalization prevents. Keeping one record per entity and reassembling at read time pushes the cost to a memoized selector that only recomputes when its inputs actually change. You trade a small, cacheable read cost for guaranteed write consistency.
How do I stop a denormalize selector from recomputing on every render?
Memoize it and key the computation on the specific slices it reads — the parent record map and the child entity map — never the whole store. With reselect or useMemo, when those input references are unchanged (structural sharing guarantees this for untouched entities) the selector returns its previous output by reference, and React.memo components downstream skip the subtree re-render entirely.
Does Apollo denormalize automatically, and when would I call readFragment myself?
Apollo denormalizes automatically when you run a query — it walks the References in the store and rebuilds the tree your selection set describes. You reach for readFragment when you need a single entity’s fields outside a query: inside a mutation update, a cache.modify, or a component that already holds an entity’s id and wants its scalars without issuing a network operation.
Related
- Nested Data Flattening Techniques — the parent reference on moving between flat and nested shapes across TanStack Query, Apollo, and normalizr.
- Flattening Deeply Nested GraphQL Responses — the inbound half of the round trip this page completes, splitting a deep tree into flat entities.
- Selector and Derivation Patterns — the memoization foundations that make denormalize selectors cheap enough to run on every render.
- Data Normalization & Query Key Design — the top-level guide covering entity mapping, relationship stitching, and the normalized model this view derives from.