Reference vs Value Storage Models
Choosing between storing cache data by reference (normalized) or by value (denormalized snapshot) is one of the earliest structural decisions in a frontend application — and one of the costliest to reverse. The wrong choice surfaces as either memory bloat and stale duplication on the value side, or selector indirection and serialization friction on the reference side. This page maps both models to their real trade-offs, explains when each fails, and provides production-ready TypeScript patterns for React Query / TanStack Query, Apollo Client v3, and Redux Toolkit.
This decision sits squarely within State Architecture & Cache Fundamentals, the parent area covering cache lifecycle, synchronization, and memory management. It is closely related to Cache Layer Architecture, which defines the separation-of-concerns boundaries where your chosen storage model must operate, and to Normalization Principles for UI, which covers the design rules that make reference storage viable at scale.
Diagnostic Checklist
You are likely in the wrong storage model if you observe any of these symptoms:
- A single
PUT /users/42mutation requires manualqueryClient.setQueryDatacalls across three or more separate query keys to keep the UI consistent — this signals value duplication that should be normalized. - A component re-renders after an unrelated query updates because both share the same deeply nested object reference — this signals a reference aliasing problem in denormalized state.
- SSR hydration throws
Converting circular structure to JSON— a reference graph contains bidirectional links that were not flattened before serialization. - Apollo
readQueryreturnsnullafter a mutation that wrote the affected entity, because thetypePoliciesmerge function replaced an array instead of merging it. structuralSharing: true(the TanStack Query v5 default) is suppressing re-renders that should fire, because two queries return structurally equal but semantically different snapshots.
Prerequisites
Before choosing a storage model, you should understand:
- How client and server state boundaries differ in ownership, invalidation authority, and lifecycle — the correct boundary determines whether shared-entity normalization is even necessary.
- The basics of how TanStack Query’s
QueryCachestores entries by serialized query key and how Apollo’sInMemoryCachestores entities by__typename:id— because each framework’s default is a different point on the reference/value spectrum. - How entity mapping strategies work at the schema level, since reference storage depends on stable, predictable entity identifiers arriving from the API.
Implementation 1: Reference-Based Normalization with Redux Toolkit createEntityAdapter
This approach is ideal when multiple queries share entities and mutation consistency must propagate instantly across the entire UI tree without re-fetching.
Steps
- Define a TypeScript entity interface with a guaranteed
idfield. - Create an entity adapter with
createEntityAdapter. RTK generatesids: string[]andentities: Record<string, T>automatically. - Write a
normalizePayloadtransform that maps raw API arrays into adapter-compatible upsert arguments. - In components, reconstruct denormalized views using
selectByIdorselectAll— never embed the raw entity map directly in JSX.
// Framework: Redux Toolkit — reference normalization
// @ts-check — all imports resolve in RTK 2.x
import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Post {
id: string;
title: string;
authorId: string;
tagIds: string[];
}
// createEntityAdapter gives you ids[], entities{} and CRUD selectors out of the box
const postsAdapter = createEntityAdapter<Post>();
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState(),
reducers: {
// Upsert merges by id — existing fields not in the payload are preserved
postsReceived(state, action: PayloadAction<Post[]>) {
postsAdapter.upsertMany(state, action.payload);
},
// Single mutation propagates to ALL components consuming this entity
postUpdated(state, action: PayloadAction<Partial<Post> & { id: string }>) {
postsAdapter.updateOne(state, { id: action.payload.id, changes: action.payload });
},
postRemoved(state, action: PayloadAction<string>) {
// Remove triggers cascade: any selector subscribed to this id gets undefined
postsAdapter.removeOne(state, action.payload);
},
},
});
export const { postsReceived, postUpdated, postRemoved } = postsSlice.actions;
// Selectors generated by the adapter — memoized by default via createSelector
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds,
} = postsAdapter.getSelectors((state: { posts: ReturnType<typeof postsSlice.reducer> }) => state.posts);
Cache Behavior Impact: When postUpdated fires, Redux Toolkit mutates the entities dictionary in the Immer draft. Because selectPostById uses createSelector memoization, only components whose specific entity changed receive a re-render signal. Components subscribed to other entities in the same slice are unaffected — this is the O(1) update propagation that value snapshots cannot provide.
Configuration Trade-offs:
createEntityAdapterdefaults toidas the sort key. If your API returns_idoruuid, passselectId: (e) => e.uuidto the factory or every lookup silently returnsundefined.upsertManyperforms a shallow merge — nested objects inside an entity are replaced entirely, not deep-merged. If your entity has a nestedmetadataobject that is partially updated by different queries, useupdateOnewith an explicit merge reducer instead.- The adapter does not cascade deletes: removing a parent entity leaves orphaned
idreferences in related entities’tagIdsorauthorIdfields. You must write a custom reducer or middleware to traverse and clean up these dangling references.
Implementation 2: Value-Based Snapshots with TanStack Query v5
Use this model for read-heavy, isolated server-state slices where entities are not shared across queries and you want minimal selector overhead.
Steps
- Configure a
QueryClientwithstaleTimeandgcTimeappropriate to the data’s volatility. - Decide on
structuralSharing: leave ittrue(the default) to preserve stable object references across re-fetches, or set itfalseto force full value replacement and guarantee re-render on every response. - In mutation handlers, call
queryClient.setQueryDatawith an updater that returns a new object reference — never mutate the cached value in place. - Use
queryClient.invalidateQueriesrather thansetQueryDatawhen the mutation affects multiple independent query keys; let each re-fetch replace its own snapshot.
// Framework: TanStack Query v5 — value snapshot configuration
import { QueryClient, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface UserSnapshot {
id: string;
name: string;
email: string;
// Full nested payload — no foreign-key indirection
recentPosts: Array<{ id: string; title: string; publishedAt: string }>;
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // treat cache as fresh for 30 s
gcTime: 5 * 60_000, // evict inactive queries after 5 min
structuralSharing: true, // default: preserve stable refs on identical shapes
refetchOnWindowFocus: true,
},
},
});
// Fetching — the snapshot is stored as-is, no normalization step
export function useUserSnapshot(userId: string) {
return useQuery<UserSnapshot>({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
}
// Mutation — update the snapshot immutably; recentPosts are embedded, not referenced
export function useUpdateUserName(userId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
fetch(`/api/users/${userId}`, { method: 'PATCH', body: JSON.stringify({ name }) })
.then(r => r.json()),
onSuccess: (updated: UserSnapshot) => {
// Replace the full snapshot — referential inequality triggers re-render
queryClient.setQueryData<UserSnapshot>(['user', userId], updated);
// If another query also embeds this user, it must be invalidated separately
queryClient.invalidateQueries({ queryKey: ['posts', { authorId: userId }] });
},
});
}
Cache Behavior Impact: With structuralSharing: true, TanStack Query v5 deep-compares the incoming fetch result against the cached value using its built-in structural equality check. If the shape is identical, the existing object reference is returned — meaning components that rely on referential stability (e.g. those wrapped in React.memo) will not re-render. Setting structuralSharing: false bypasses this check entirely: every fetch response replaces the cache entry with a new object, guaranteeing re-render regardless of content changes. The gcTime clock begins when a query has no active subscribers; after expiry, the entry is removed from QueryCache and the next mount triggers a fresh network request.
Configuration Trade-offs:
structuralSharing: truemeans a cachedrecentPostsarray is preserved by reference if its content did not change. This is efficient, but if you mutate the array in place (a bug) rather than replacing it, the structural check passes and the stale mutation is silently retained.- Setting
gcTime: 0combined withstaleTime: 0creates a cache that is effectively stateless — every unmount evicts the data. Useful for security-sensitive pages but eliminates background refetch benefits. - If a
UserSnapshotembeds the samePostobject that also exists in a separate['posts', id]query key, updating the post via the posts key does not update the embedded copy in the user snapshot. This is the consistency gap that makes value storage unsuitable for highly relational data.
Implementation 3: Apollo InMemoryCache with typePolicies for Field-Level Merge Control
Apollo sits between the two pure models: it normalizes entities by __typename:id automatically, but requires explicit typePolicies to handle non-standard merges at the field level. This section covers the specific configuration needed to avoid the default “replace” behavior that discards paginated edges or partial updates.
Steps
- Confirm your GraphQL schema returns
idon every entity type, or configurekeyFieldsintypePoliciesfor types that use a non-standard identifier. - Write a
mergefunction for any field that aggregates data across queries (pagination edges, union types, computed lists). - Write a corresponding
readfunction to reconstruct the denormalized view from the normalized store. - Test with
cache.readQueryimmediately aftercache.writeQueryto confirm the merge function did not discard data.
// Framework: Apollo Client v3 — typePolicies for controlled merging
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
User: {
// Apollo normalizes by __typename + keyFields; default is 'id'
keyFields: ['id'],
fields: {
posts: {
// Without a merge fn, Apollo replaces the posts array on every write.
// This merge fn accumulates pages without duplicates.
merge(existing: any[] = [], incoming: any[]) {
const seen = new Set(existing.map((ref: any) => ref.__ref));
const unique = incoming.filter((ref: any) => !seen.has(ref.__ref));
return [...existing, ...unique];
},
// read fn reconstructs the flat array for consumers — no change needed here
read(existing) {
return existing;
},
},
},
},
Post: {
keyFields: ['id'],
fields: {
// Shallow-merge optimistic updates into existing Post fields
title: {
merge(_, incoming) { return incoming; },
},
},
},
},
});
export const client = new ApolloClient({
uri: '/graphql',
cache,
});
// Verify the merge after a cache write — readQuery must return the full accumulated list
const USER_WITH_POSTS = gql`
query UserWithPosts($id: ID!) {
user(id: $id) {
id
name
posts { id title }
}
}
`;
Cache Behavior Impact: Apollo’s InMemoryCache stores User:42 and Post:7 as separate normalized entries keyed by __typename:id. When a query response arrives, Apollo walks the response, extracts each entity by its key, and writes it to the normalized store. The typePolicies.User.fields.posts.merge function intercepts the write for the posts field specifically — without it, Apollo replaces the array wholesale, discarding previously loaded pages. The read function controls what shape is returned when a component calls useQuery — Apollo always re-executes read on every cache read, so you can reconstruct sorted or filtered views without writing back to the cache.
Configuration Trade-offs:
- If
keyFieldsis not configured for a type and Apollo cannot find anidfield, the entity is stored inline (denormalized) in the parent entity’s cache entry. This silently reverts to value storage for that type, breaking the update propagation guarantees you expect from normalization. mergefunctions run synchronously on the Apollo cache write thread. Expensive deduplication logic (e.g. deep comparison of large arrays) blocks the render cycle. Prefer__ref-based Set lookups over deep equality for O(1) deduplication.cache.evict({ id: cache.identify(obj) })followed bycache.gc()is required to fully remove a normalized entity and its dangling references. Callingcache.evictalone leaves orphaned__refpointers in parent entities’ field arrays.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Mutation updates one component but a sibling showing the same entity stays stale | Value snapshot duplication: the entity exists in two independent query keys with no shared reference | Switch the affected entity to reference normalization (RTK upsertMany or Apollo typePolicies), or add explicit setQueryData calls for every affected key in the mutation’s onSuccess handler |
JSON.stringify throws TypeError: Converting circular structure to JSON during SSR hydration |
Reference graph contains bidirectional links (parent → child → parent) that were stored as direct object references instead of ID strings | Flatten the graph before cache write using circular reference handling patterns; store parentId/childIds instead of nested objects |
Apollo useQuery returns the same stale data after a mutation that called cache.writeQuery |
The merge function in typePolicies did not handle the incoming data correctly and the write was silently dropped or no-op’d |
Run cache.readQuery immediately after cache.writeQuery in the browser console; if the result is wrong, add logging inside the merge function to inspect existing vs incoming |
TanStack Query component does not re-render after setQueryData |
The updater function returned the same object reference (mutation-in-place); structuralSharing detected identity equality and suppressed the re-render |
Always return a new object from the updater: (old) => ({ ...old, name: updated.name }) — never mutate old directly |
RTK entity adapter selectById returns undefined after an API response that clearly contains the entity |
selectId is using e.id but the API returns e._id or e.uuid — the entity was stored under the wrong key |
Pass a custom selectId to createEntityAdapter: createEntityAdapter<T>({ selectId: (e) => e._id }) |
Frequently Asked Questions
When does reference normalization cause more harm than value snapshots in React Query?
When queries are isolated and non-relational — such as dashboard summary cards or user-preferences slices — normalization adds overhead (selector indirection, entity map maintenance) without the consistency benefit. If two queries never share an entity, the deduplication benefit is zero and the added resolver complexity is a net loss. Value snapshots with appropriate staleTime and gcTime configuration are the simpler and faster choice for these patterns.
Does disabling structuralSharing in TanStack Query v5 force a full value replacement?
Yes. When structuralSharing is false, TanStack Query replaces the entire cache entry on every fetch response, regardless of whether the data changed. This guarantees referential inequality and triggers component re-renders unconditionally. The trade-off is increased GC pressure and loss of the stable-reference optimization that prevents unnecessary downstream renders. Reserve this setting for cases where you explicitly need guaranteed re-renders, such as live data streams or real-time dashboards.
How does Apollo InMemoryCache decide to merge or replace normalized entities on field-level updates?
Apollo’s InMemoryCache uses typePolicies with a merge function per field. Without a merge function, a write to an existing field replaces its value entirely. With a custom merge, you can deep-merge arrays, deduplicate edges, or preserve optimistic fields during a server reconciliation. The read function then reconstructs the denormalized view for component consumption. Testing this pairing with cache.readQuery immediately after cache.writeQuery is the fastest way to confirm the policy behaves as expected.
Can I mix reference and value models within the same application?
Yes, and it is often the correct choice. Relational data with shared entities (users, products, comments) benefits from reference normalization. Isolated server responses (analytics snapshots, export jobs) are better stored as value snapshots. The key discipline is explicit ownership boundaries — never let a normalized entity silently embed a value-snapshot entity or the update paths diverge unpredictably. The client vs server state boundaries principle applies here: once you define which cache owns an entity, that cache’s model governs all reads and writes for it.
Related
- State Architecture & Cache Fundamentals — the parent area covering cache lifecycle design, synchronization primitives, and memory management strategies that frame the reference/value decision.
- Cache Layer Architecture — how to structure the cache layer itself so that your chosen storage model integrates cleanly with framework adapter config and background synchronization hooks.
- Handling Circular References in Cache — the specific failure mode that emerges when bidirectional object graphs are stored by reference without cycle-breaking, including
WeakSettraversal guards andstructuredClonefallbacks. - Normalization Principles for UI — the design rules (stable
ids, flat entity maps, foreign-key arrays) that make reference storage predictable and maintainable. - Entity Mapping Strategies — practical patterns for mapping server payload shapes to normalized cache keys, including composite key strategies and schema-driven ID extraction.