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.
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 withkeyFields: falsefor embedded types. - TanStack Query defaults to nested documents; dial up by lifting entities to their own keys via
setQueryData. - RTK Query’s
createEntityAdaptergives 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.
Related
- Normalization Principles for UI â the parent reference on why UI caches normalize at all and how depth trades read cost against write fan-out.
- How to Design a Normalized State Tree â the concrete shape and selector patterns once you have decided to flatten shared entities.
- Entity Mapping Strategies â how to define the stable ID contract that determines what counts as a shared entity worth normalizing.
- State Architecture & Cache Fundamentals â the foundational pillar connecting normalization, storage models, and cache-layer architecture.