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.postCount on 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 exceeded error
  • Adding a new entity to a list returns undefined for 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: typePolicies already configured with keyFields so the cache can identify entities by stable IDs

Relationship stitching data flow Write path: nested API response is normalized into flat User, Post, and Tag entity tables. Read path: memoized selectors stitch those tables back into a hydrated object graph for the UI component. WRITE PATH API Response { post: { author: {...}, tags: [...] } } Normalizer extract entities, replace nested objects with IDs Entity Store (flat) users['u1'] = { id, name } posts['p1'] = { id, title, authorId: 'u1', tagIds: ['t1','t2'] } tags['t1'] = { id, label } READ PATH Memoized Selector resolve IDs → reconstruct graph Hydrated Graph { post: { title, author: {name}, tags: [{label}] } } UI Component Circular Ref Guard visited Set + maxDepth prevents infinite recursion on A↔B graphs

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:

  1. Configure your query fetchers so nested objects are stripped to IDs at the network boundary — either in the queryFn itself or in a shared response transformer.
  2. Keep per-entity query keys deterministic and narrow: ['user', userId], ['post', postId], ['posts', 'byUser', userId] for the ID list.
  3. In the consuming component, call useQueryClient and build a useMemo that reads individual entities via getQueryData, using the list query’s result as the ID source.
  4. Add a visited Set 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:

  • staleTime on 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 increases getQueryData misses in stitched selectors — consider raising gcTime to 30 minutes for heavily referenced entities
  • structuralSharing (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:

  1. Identify every “list” query key that supplies IDs for a given entity type (e.g. ['posts', 'byUser', userId]).
  2. In the mutation’s onSuccess handler, call invalidateQueries for both the mutated entity key and every list query that might reference it.
  3. For optimistic updates, snapshot the current entity in onMutate, apply a setQueryData update immediately, then restore the snapshot in onError.
// 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 for invalidateQueries) only refetches queries with active subscribers; use refetchType: 'all' when you need to refresh queries mounted in hidden tabs or prefetched for navigation
  • Snapshot restoration in onError relies on the snapshot being taken before setQueryData fires; always await cancelQueries first to prevent an in-flight refetch from racing with the snapshot
  • For high-frequency mutations (e.g. real-time collaborative editing), debounce invalidateQueries calls or switch to setQueryData for 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:

  1. Configure typePolicies with keyFields for every entity type so Apollo assigns stable cache IDs.
  2. Use cache.modify to patch individual entity fields without triggering a network request.
  3. For mutations that add or remove entities from lists, update the list field’s reference array inside the same cache.modify call.
// 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.keyFields must be configured before first use; changing keyFields after data is cached orphans existing entries because the cache ID changes — plan your key strategy before writing data
  • cache.modify operates only on fields of the identified entity; it cannot add new entities to the cache — use cache.writeFragment or cache.writeQuery for that
  • broadcastWatches (called internally after every modify/writeFragment) triggers a synchronous re-render of all active queries; batching multiple field patches inside a single cache.modify call 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.