Relationship Stitching in Cache
Normalized caches store entities flat — a User row, a Post row, each keyed by ID. But UI components need fully hydrated objects: a post with its author name, its comment count, its tag labels. The gap between those two representations is where relationship stitching lives, and closing it incorrectly is the root cause of some of the most persistent bugs in production state management: stale nested objects after partial mutations, infinite re-render loops from circular entity graphs, and memory bloat from duplicated payloads.
This topic sits inside Data Normalization & Query Key Design, the broader discipline of transforming server payloads into flat, reference-stable client stores. Before relationship stitching can work correctly, the write path must already normalize raw responses — a problem addressed in Entity Mapping Strategies. If your responses contain deeply nested arrays that you haven’t yet separated into per-type tables, start with Nested Data Flattening Techniques first.
Diagnostic checklist
You need this if you observe any of the following in production:
- A mutation updates
post.author.postCounton the server but the UI still shows the old count after the mutation resolves - Components display stale relationship data until a full page reload, even though individual entity queries are fresh
- A recursive selector causes the browser to hang or throws a
Maximum call stack size exceedederror - Adding a new entity to a list returns
undefinedfor related fields because the referenced entity was never fetched - Two components displaying the same entity show different values after an optimistic update because each holds its own nested copy
Prerequisites
Before implementing relationship stitching, confirm you have:
- A flat, ID-keyed entity store (via Entity Mapping Strategies or a framework normalizer)
- Deterministic query key shapes for per-entity lookups (e.g.
['user', id],['post', id]) — see Designing Stable Query Keys for React Query - A clear separation between server state (what gets stitched) and presentation state (
isExpanded,selectedTab), which must stay outside the normalized store - For Apollo users:
typePoliciesalready configured withkeyFieldsso the cache can identify entities by stable IDs
Implementation 1 — Foreign Key Resolution with Lazy Stitching (React Query)
Lazy stitching defers graph reconstruction entirely to the selector layer. The write path stores scalar IDs; a useMemo hook resolves them on render.
Steps:
- Configure your query fetchers so nested objects are stripped to IDs at the network boundary — either in the
queryFnitself or in a shared response transformer. - Keep per-entity query keys deterministic and narrow:
['user', userId],['post', postId],['posts', 'byUser', userId]for the ID list. - In the consuming component, call
useQueryClientand build auseMemothat reads individual entities viagetQueryData, using the list query’s result as the ID source. - Add a
visitedSet to the memo function to guard against bidirectional relationships.
// React Query v5 — lazy relationship stitching with circular reference guard
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
interface User { id: string; name: string; teamId: string }
interface Team { id: string; name: string; memberIds: string[] }
interface Post { id: string; title: string; authorId: string }
function resolveUser(
userId: string,
client: ReturnType<typeof useQueryClient>,
visited = new Set<string>(),
depth = 0,
): (User & { team?: Team; posts?: Post[] }) | null {
if (visited.has(userId) || depth > 2) return null;
visited.add(userId);
const user = client.getQueryData<User>(['user', userId]);
if (!user) return null;
const team = client.getQueryData<Team>(['team', user.teamId]) ?? undefined;
const postIds = client.getQueryData<string[]>(['posts', 'byUser', userId]) ?? [];
const posts = postIds
.map((id) => client.getQueryData<Post>(['post', id]))
.filter((p): p is Post => p !== undefined);
return { ...user, team, posts };
}
export function useStitchedUser(userId: string) {
const client = useQueryClient();
// Fetch the user entity
const { data: user } = useQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 60_000,
});
// Fetch the flat list of post IDs for this user
const { data: postIds } = useQuery<string[]>({
queryKey: ['posts', 'byUser', userId],
queryFn: () => fetch(`/api/users/${userId}/post-ids`).then((r) => r.json()),
staleTime: 30_000,
enabled: !!user,
});
// Reconstruct the relational graph at read time
return useMemo(
() => resolveUser(userId, client),
// eslint-disable-next-line react-hooks/exhaustive-deps
[userId, user, postIds, client],
);
}
Cache Behavior Impact: getQueryData is a synchronous, zero-network read from React Query’s in-memory QueryCache. The useMemo recomputes only when its declared dependencies change — meaning a mutation that writes a new User entity via setQueryData(['user', userId], ...) will cause the memo to recompute on the next render, producing a fresh hydrated graph without any refetch. If a referenced entity is absent from the cache (not yet fetched or evicted after gcTime elapsed), the lookup returns undefined; the filter in the posts array handles this gracefully.
Configuration trade-offs:
staleTimeon entity queries controls how long the flat entities remain fresh; raise it for stable reference data (users, teams) and lower it for volatile content (post bodies)gcTime(default 5 minutes) governs how long unused entities remain in memory; shortening it reduces memory pressure but increasesgetQueryDatamisses in stitched selectors — consider raisinggcTimeto 30 minutes for heavily referenced entitiesstructuralSharing(enabled by default in React Query v5) means the memo only sees a new object reference when data actually changes, preventing spurious re-renders from stitched selectors
Implementation 2 — Cascade Invalidation for Relational Mutations (React Query)
When a mutation changes a child entity, the parent list query that supplies the ID array must also be invalidated. Without this, stitched graphs reconstruct correctly but use a stale ID set.
Steps:
- Identify every “list” query key that supplies IDs for a given entity type (e.g.
['posts', 'byUser', userId]). - In the mutation’s
onSuccesshandler, callinvalidateQueriesfor both the mutated entity key and every list query that might reference it. - For optimistic updates, snapshot the current entity in
onMutate, apply asetQueryDataupdate immediately, then restore the snapshot inonError.
// React Query v5 — cascade invalidation on post mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface PostUpdate { id: string; title: string; authorId: string }
interface Post extends PostUpdate {}
export function useUpdatePost() {
const client = useQueryClient();
return useMutation<Post, Error, PostUpdate>({
mutationFn: (update) =>
fetch(`/api/posts/${update.id}`, {
method: 'PATCH',
body: JSON.stringify(update),
headers: { 'Content-Type': 'application/json' },
}).then((r) => r.json()),
onMutate: async (update) => {
// Cancel any outgoing refetches to prevent race conditions
await client.cancelQueries({ queryKey: ['post', update.id] });
// Snapshot the current entity for rollback
const previousPost = client.getQueryData<Post>(['post', update.id]);
// Optimistically update the entity in the flat store
client.setQueryData<Post>(['post', update.id], (old) =>
old ? { ...old, ...update } : undefined,
);
return { previousPost };
},
onError: (_err, update, context) => {
// Roll back the entity to its previous state
if (context?.previousPost) {
client.setQueryData(['post', update.id], context.previousPost);
}
},
onSuccess: (updatedPost) => {
// Patch the entity with the authoritative server response
client.setQueryData(['post', updatedPost.id], updatedPost);
// Cascade: invalidate the per-entity query so it refetches on next mount
client.invalidateQueries({ queryKey: ['post', updatedPost.id] });
// Cascade: invalidate the author's post-id list so stitched graphs rebuild
client.invalidateQueries({
queryKey: ['posts', 'byUser', updatedPost.authorId],
});
},
});
}
Cache Behavior Impact: cancelQueries prevents a background refetch from overwriting the optimistic update. setQueryData writes synchronously into the QueryCache and marks the affected query as updated, causing any component subscribed via useQuery(['post', id]) to re-render immediately with the new value. When onSuccess calls invalidateQueries, React Query marks those keys stale and schedules a background refetch — the stale value is served immediately while the fresh value loads in the background, preserving the SWR (stale-while-revalidate) behaviour built into the library.
Configuration trade-offs:
refetchType: 'active'(default forinvalidateQueries) only refetches queries with active subscribers; userefetchType: 'all'when you need to refresh queries mounted in hidden tabs or prefetched for navigation- Snapshot restoration in
onErrorrelies on the snapshot being taken beforesetQueryDatafires; alwaysawait cancelQueriesfirst to prevent an in-flight refetch from racing with the snapshot - For high-frequency mutations (e.g. real-time collaborative editing), debounce
invalidateQueriescalls or switch tosetQueryDatafor all updates to avoid waterfall refetch storms
Implementation 3 — Pointer-Level Relational Updates (Apollo Client v3)
Apollo’s normalized InMemoryCache maintains entity references by stable cache ID (e.g. Post:p1). Patching a referenced entity propagates automatically to every query that holds a reference to it — no manual cascade needed for entity fields, only for list membership changes.
Steps:
- Configure
typePolicieswithkeyFieldsfor every entity type so Apollo assigns stable cache IDs. - Use
cache.modifyto patch individual entity fields without triggering a network request. - For mutations that add or remove entities from lists, update the list field’s reference array inside the same
cache.modifycall.
// Apollo Client v3 — pointer-level patch with list membership update
import { useApolloClient, gql } from '@apollo/client';
const AUTHOR_FIELDS = gql`
fragment AuthorFields on Author {
postCount
updatedAt
}
`;
interface UpdatedPost {
id: string;
author: { id: string; postCount: number };
}
export function usePatchPostAuthor() {
const client = useApolloClient();
return (updatedPost: UpdatedPost) => {
// Patch the Author entity in-place at the reference level
client.cache.writeFragment({
id: client.cache.identify({ __typename: 'Author', id: updatedPost.author.id }),
fragment: AUTHOR_FIELDS,
data: {
postCount: updatedPost.author.postCount,
updatedAt: new Date().toISOString(),
},
});
// Patch the Post entity to update any scalar fields
client.cache.modify({
id: client.cache.identify({ __typename: 'Post', id: updatedPost.id }),
fields: {
// Return the same author reference — Apollo resolves the pointer automatically
author: (existingRef) => existingRef,
},
});
// If the post moves between lists, update the membership reference arrays here
// e.g. remove from old author's posts list and add to new author's posts list
};
}
Cache Behavior Impact: cache.identify resolves to the stable Author:${id} cache key via the keyFields configuration. writeFragment writes directly into the normalized store at that key — because every Apollo query that touches this Author holds a reference pointer rather than a copy, all active queries see the updated postCount immediately on the next render cycle without any network round-trip. This is the primary performance advantage of Apollo’s reference-based normalization over copying nested objects into query results.
Configuration trade-offs:
typePolicies.keyFieldsmust be configured before first use; changingkeyFieldsafter data is cached orphans existing entries because the cache ID changes — plan your key strategy before writing datacache.modifyoperates only on fields of the identified entity; it cannot add new entities to the cache — usecache.writeFragmentorcache.writeQueryfor thatbroadcastWatches(called internally after everymodify/writeFragment) triggers a synchronous re-render of all active queries; batching multiple field patches inside a singlecache.modifycall avoids multiple render cycles
Configuration Trade-offs Summary
| Concern | React Query (lazy selector) | Apollo Client (reference pointer) |
|---|---|---|
| Write-path complexity | Low — store IDs, no adapter config | Medium — typePolicies.keyFields required |
| Read-path CPU | useMemo recomputes on dep change; scales with entity count |
Zero — reference resolution is O(1) pointer lookup |
| Cascade on mutation | Manual invalidateQueries for list keys |
Automatic for entity fields; manual for list membership |
| Memory model | gcTime-bounded; entities evicted when unused |
Entities live until cache.evict is called; no automatic GC |
| Circular ref handling | Must implement visited Set in selector |
Apollo normalizes references and never recursively expands |
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
Component shows stale author name after updatePost mutation |
Mutation invalidates ['post', id] but not ['user', authorId]; the User entity is a separate query key that wasn’t touched |
Add invalidateQueries({ queryKey: ['user', updatedPost.authorId] }) inside onSuccess. Audit your invalidation chains by mapping every entity type to the list of query keys that reference it. |
Maximum call stack size exceeded in a selector that traverses User → Team → User |
The resolver descends into Team.members, finds a User again, and recurses without a termination condition |
Add a visited = new Set<string>() parameter to the resolver and return a stub { id, __typename } on revisit. Set depth > 2 as a hard guard. |
getQueryData returns undefined for a referenced entity that was definitely fetched |
The query key used during the original fetch does not exactly match the key passed to getQueryData; even an extra undefined element in the key array causes a mismatch in React Query v5 |
Print both keys with JSON.stringify and compare. Centralize query key factories (e.g. postKeys.detail(id)) so the key shape is defined in one place and shared by fetchers and selectors. |
Apollo: updating Author.postCount via writeFragment does not refresh the component |
keyFields is not configured for Author, so Apollo cannot identify the entity and writes to a detached cache entry instead |
Add typePolicies: { Author: { keyFields: ['id'] } } to InMemoryCache config. Verify the cache ID with client.cache.identify({ __typename: 'Author', id }) — it must return a string, not undefined. |
| Memory grows unbounded as users navigate between entity detail pages | gcTime is set to Infinity or entities are never evicted; every visited entity accumulates in the QueryCache |
Set gcTime to a finite value (default 5 minutes works for most apps). For Apollo, call cache.evict + cache.gc() in a route-change cleanup hook to release entities from departed routes. |
Frequently Asked Questions
Should relationship resolution happen at write time or read time?
Read-time resolution is the production standard. Write-time hydration copies nested objects into every dependent query entry, which doubles memory consumption and forces you to locate and patch every stale copy on mutation. Read-time resolution stores only IDs at write, then reconstructs the graph in memoized selectors — mutation payloads stay minimal and a single entity update propagates to every selector that references it. Use write-time hydration only for immutable reference data (e.g. country lists, static lookups) where the cache never needs to patch the nested copy.
How do I break circular references during selector traversal without losing data?
Maintain a visited Set keyed by entity ID throughout the recursive traversal. On each node, check membership before descending; if already visited, return a stub object containing only the id and type fields. This prevents infinite recursion while preserving enough data for the UI to display a label or navigate. Set maxDepth to 2–3 in your selector config as a second safety net — deeper graphs almost always indicate a data-modelling problem rather than a legitimate UI requirement.
Does React Query v5 invalidateQueries propagate to nested entity lookups automatically?
No. invalidateQueries marks matching query keys stale and schedules a background refetch, but it operates on query keys, not entity references. If your stitched graph is built inside a useMemo that reads from queryClient.getQueryData, those synchronous lookups will return the freshly-fetched entity immediately after the dependent query refetches — no extra invalidation needed for the stitched result. Where things break: if you invalidate ['post', id] but never invalidate ['posts', 'byUser', userId], the per-user list query keeps serving the old ID set. Model your invalidation chains around the list queries that supply ID arrays, not just the individual entity queries.
What causes undefined entries when resolving entity references from the cache?
queryClient.getQueryData returns undefined when the referenced entity has never been fetched, has been garbage-collected (gcTime elapsed), or its query key does not exactly match the key used during the original fetch. Debug in order: (1) log the missing ID and verify the query key shape matches exactly — even trailing undefined params create mismatches in React Query v5; (2) check gcTime and raise it for entities you reference often; (3) add a prefetchQuery call in your list query’s onSuccess handler to eagerly populate the entity cache before the stitching selector runs.
Related
- Data Normalization & Query Key Design — the parent discipline: how to design flat, reference-stable stores that make stitching tractable
- Entity Mapping Strategies — normalize raw API responses into the flat entity tables that stitching reads from
- Nested Data Flattening Techniques — separate deeply nested payloads into per-type tables before attempting relational reconstruction
- Pagination Normalization Patterns — handle ID-list queries for paginated collections without cache fragmentation, which feeds directly into stitched list resolution
- Stale-While-Revalidate Implementation — understanding how SWR revalidation interacts with stitched queries that hold references to independently-cached entities