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.


Reference vs Value Storage Models — architecture comparison Left side shows reference (normalized) storage: a flat entity map keyed by ID with foreign-key arrays pointing between records. Right side shows value (denormalized) storage: each query slot holds a complete nested object tree. A dividing line separates the two models with labels at top and bottom indicating trade-offs. Reference (Normalized) Value (Denormalized Snapshot) entities: Record<string, Entity> "user:42" { id, name, postIds } "post:7" { id, title, authorId } "post:9" { id, title, authorId } ids: string[] "post:7" "post:9" ... O(1) update · shared across queries selector indirection · SSR complexity queryCache: Map<key, Snapshot> ['user', 42] { id, name, posts: [ { id, title }, ... ] } complete nested tree ['posts', { userId: 42 }] [ { id, title, author: { id, name } }, ... ] ^ author duplicated across both slots simple selectors · trivial hydration memory duplication · stale consistency risk Each model has distinct ownership semantics — the choice depends on how entities are shared across queries

Diagnostic Checklist

You are likely in the wrong storage model if you observe any of these symptoms:

  • A single PUT /users/42 mutation requires manual queryClient.setQueryData calls 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 readQuery returns null after a mutation that wrote the affected entity, because the typePolicies merge 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 QueryCache stores entries by serialized query key and how Apollo’s InMemoryCache stores 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

  1. Define a TypeScript entity interface with a guaranteed id field.
  2. Create an entity adapter with createEntityAdapter. RTK generates ids: string[] and entities: Record<string, T> automatically.
  3. Write a normalizePayload transform that maps raw API arrays into adapter-compatible upsert arguments.
  4. In components, reconstruct denormalized views using selectById or selectAll — 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:

  • createEntityAdapter defaults to id as the sort key. If your API returns _id or uuid, pass selectId: (e) => e.uuid to the factory or every lookup silently returns undefined.
  • upsertMany performs a shallow merge — nested objects inside an entity are replaced entirely, not deep-merged. If your entity has a nested metadata object that is partially updated by different queries, use updateOne with an explicit merge reducer instead.
  • The adapter does not cascade deletes: removing a parent entity leaves orphaned id references in related entities’ tagIds or authorId fields. 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

  1. Configure a QueryClient with staleTime and gcTime appropriate to the data’s volatility.
  2. Decide on structuralSharing: leave it true (the default) to preserve stable object references across re-fetches, or set it false to force full value replacement and guarantee re-render on every response.
  3. In mutation handlers, call queryClient.setQueryData with an updater that returns a new object reference — never mutate the cached value in place.
  4. Use queryClient.invalidateQueries rather than setQueryData when 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: true means a cached recentPosts array 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: 0 combined with staleTime: 0 creates a cache that is effectively stateless — every unmount evicts the data. Useful for security-sensitive pages but eliminates background refetch benefits.
  • If a UserSnapshot embeds the same Post object 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

  1. Confirm your GraphQL schema returns id on every entity type, or configure keyFields in typePolicies for types that use a non-standard identifier.
  2. Write a merge function for any field that aggregates data across queries (pagination edges, union types, computed lists).
  3. Write a corresponding read function to reconstruct the denormalized view from the normalized store.
  4. Test with cache.readQuery immediately after cache.writeQuery to 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 keyFields is not configured for a type and Apollo cannot find an id field, 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.
  • merge functions 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 by cache.gc() is required to fully remove a normalized entity and its dangling references. Calling cache.evict alone leaves orphaned __ref pointers 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.


  • 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 WeakSet traversal guards and structuredClone fallbacks.
  • 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.