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 to items × 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.

Reassembling flat entities into a render-ready tree On the left, a normalized store holds a posts map and a users map separately. A memoized denormalize selector in the middle resolves each post's authorId against the users map. On the right, the output is a tree of post objects each with its author inlined. Normalized store posts p1 { authorId: u9 } p2 { authorId: u9 } p3 { authorId: u4 } users u9 { name: "Ada" } u4 { name: "Grace" } one record per entity memoized denormalize for each post: users[post.authorId] recompute only when posts or users change Render-ready tree post p1 author: { name: "Ada" } inlined, ready to render post p2 author: same "Ada" ref post p3 author: { name: "Grace" } ✓ shared author reference reused
The selector resolves each post's authorId against the users map once; p1 and p2 receive the same author object by reference, so React can skip untouched subtrees.

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 — use createSelectorCreator with a larger cache or a per-instance selector via useMemo. denormalize rebuilds the whole subtree when any input entity changes; for very deep graphs, denormalize per-visible-item rather than the entire list. readFragment returns null on 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.