Choosing Normalization Depth for UI State

Normalization is not a binary you turn on; it is a dial. At one extreme you keep the server’s response as a nested document and render straight off it. At the other you shred every response into flat entity tables keyed by ID, joining them back at read time. Most production caches live somewhere in between, and choosing where is the single decision that most shapes update cost, read complexity, and memory. This page is a practical guide to setting that dial, and it sits within Normalization Principles for UI. If you have already decided to go flat and now need the actual shape, the sibling walkthrough on how to design a normalized state tree picks up where this one ends.

The core tension is simple. Deeper normalization dedupes shared entities so a single write updates every view — but it moves work to read time, where you must reassemble the graph. Shallower nesting makes reads trivial but forces you to fan a mutation out to every copy. The right depth is whichever minimizes total churn for your read/write ratio, and it is inseparable from your entity mapping strategy — the ID contract that decides what a “shared entity” even is.

The Three Depths

  • Nested document (depth 0): store the response as-is. Trivial reads, zero join logic, but a shared entity is duplicated per query and every copy must be updated on write.
  • Shallow references (depth 1): top-level lists hold ID references; entity bodies live in one table. Shared entities dedupe; reads do a single lookup layer.
  • Flat entity tables (depth 2+): every relationship is a reference; the cache is a relational store. Maximum dedup and single-write fan-out, maximum read-time reassembly.

Normalization depth spectrum and its cost tradeoff Three stacked panels from top to bottom. Nested document keeps a post with its author inline. Shallow references store post lists pointing to an author table. Flat entity tables store posts, authors, and comments as separate keyed tables. A gradient bar shows read cost rising and write cost falling as depth increases. Nested document depth 0 post 1 author: {name} post 2 author: {name} read: trivial write: fan-out Shallow references depth 1 posts [authorId] 1 → a7   2 → a7 authors table a7 → {name} shared entity deduped Flat entity tables depth 2+ posts: {id → body} authors: {id → body} comments: {id → body} read: reassemble write: single upsert
As depth increases, shared entities dedupe and writes become single upserts, but reads must reassemble the graph.

Step-by-Step: Setting the Dial

Step 1 — Inventory shared entities

List the entities in your domain and mark which ones appear in more than one view. A User that shows in a header, a comment list, and a settings page is shared three ways — normalize it. A Notification rendered only in one dropdown is not shared — leave it nested. Normalization only pays off for entities that are read from multiple places or written while visible elsewhere.

// A quick audit shape — one row per entity
type EntityAudit = {
  entity: string;
  viewsThatRead: number;
  writtenWhileOtherViewsMounted: boolean;
};

const audit: EntityAudit[] = [
  { entity: 'User', viewsThatRead: 3, writtenWhileOtherViewsMounted: true },  // normalize
  { entity: 'Notification', viewsThatRead: 1, writtenWhileOtherViewsMounted: false }, // keep nested
];

Cache Behavior Analysis. Entities with viewsThatRead > 1 are the only ones where a flat table saves you a write fan-out; for single-view entities the flat table adds an ID lookup on every read with zero dedup upside. This audit turns “how deep” into a per-entity decision rather than a global one.

Step 2 — Encode depth in Apollo’s InMemoryCache

Apollo normalizes automatically by __typename:id — it is a depth-2 store by default. You raise or lower effective depth by declaring keyFields and field policies, not by restructuring responses.

import { InMemoryCache } from '@apollo/client';

export const cache = new InMemoryCache({
  typePolicies: {
    User: {
      keyFields: ['id'], // normalized into a flat User table by __typename:id
    },
    Post: {
      fields: {
        author: {
          merge: true, // merge nested author into the referenced User entity
        },
      },
    },
  },
});

Cache Behavior Analysis. Because Apollo flattens every object carrying a __typename and id into the root store, a User returned inside a Post and the same User fetched standalone resolve to one store slot. Writing that user once — via a mutation result or cache.modify — updates every query reading it. You opt out of normalization per type with keyFields: false, which pins that type as an embedded nested value.

Step 3 — Encode depth in TanStack Query and RTK Query

TanStack Query stores document-style by query key with no entity extraction, so its default is depth 0. To dedupe you either lift shared entities into their own query keys or write them across keys manually.

import { useQueryClient } from '@tanstack/react-query';

function usePromoteUser() {
  const qc = useQueryClient();
  return (user: { id: string; name: string; role: string }) => {
    // Write the entity into its own key AND patch any list documents holding it
    qc.setQueryData(['user', user.id], user);
    qc.setQueryData(['users', 'list'], (old: any[] = []) =>
      old.map((u) => (u.id === user.id ? user : u)),
    );
  };
}

Cache Behavior Analysis. Each setQueryData targets one document — TanStack Query never joins keys for you, so the fan-out is explicit. RTK Query’s createEntityAdapter takes the opposite stance: it maintains a normalized { ids, entities } slice with upsertOne giving an O(1) single-write update and memoized selectById/selectAll selectors, which is the ergonomic flat-table depth without hand-rolling the reducer.

  • Apollo defaults to flat (__typename:id); dial down with keyFields: false for embedded types.
  • TanStack Query defaults to nested documents; dial up by lifting entities to their own keys via setQueryData.
  • RTK Query’s createEntityAdapter gives flat tables with memoized selectors — reach for it when data already lives in Redux.

Edge Cases

Polymorphic lists. A feed mixing Post, Ad, and Milestone resists a single flat table — normalize each type separately and keep the ordering array as a list of { __typename, id } references. Ephemeral derived fields. Do not normalize computed presentation fields (isSelected, highlightedRange); they belong in component or client state, not the entity table, or every derived write invalidates shared readers. Deeply recursive graphs. Threaded comments can normalize into infinite reference chains; cap the depth you flatten and keep leaves nested to avoid selector cost exploding on deep trees.


Common Pitfalls and Resolutions

Observable Issue Root Cause Diagnostic Resolution
One entity renders stale in a second view after an edit Nested document depth — the entity is duplicated per query and only one copy was written Lift the entity to a shared key (setQueryData(['user', id])) or rely on Apollo __typename:id normalization so one write updates all readers
Rendering a simple card triggers three table joins Over-normalized — a single-view entity was flattened with no dedup benefit Keep single-view entities nested; reserve flat tables for entities with viewsThatRead > 1
Selectors recompute on unrelated entity writes Flat store selector reads too broad a slice, so any table mutation invalidates it Use createEntityAdapter’s memoized selectById, or narrow the selector to the specific IDs a component reads

Frequently Asked Questions

How do I know if I have over-normalized my UI state?

The tells are: components that join three or more entity tables to render one card, selectors that recompute on writes to entities they do not read, and denormalization glue that is bulkier than the render logic it feeds. If an entity is never shared across two views, a flat table only adds lookups and indirection with no dedup payoff — keep it nested and revisit only if sharing appears.

Does TanStack Query normalize entities like Apollo does?

No. TanStack Query stores each response document-style under its query key with no entity extraction, so the same user fetched under two keys is two independent copies. Apollo’s InMemoryCache normalizes automatically by __typename:id into a flat store. To get flat entities in TanStack Query you build them yourself with setQueryData and a disciplined query key factory.

When should I reach for RTK Query createEntityAdapter?

Use createEntityAdapter when data already lives in a Redux slice, you need memoized selectById/selectAll selectors, and you want O(1) upsertOne writes into a normalized { ids, entities } shape. It is the right depth when many components read the same entity by ID and you want stable selector references without hand-rolling the normalized table and its reducers.