Resolving One-to-Many Relationships in Cache
An author has many posts; a thread has many comments; an order has many line items. These one-to-many relationships are the backbone of almost every app, and the way you model them in a normalized cache decides whether an edit to one post updates cleanly everywhere or leaves half your screens stale. The durable answer is to store the relationship as references — a parent that holds an ordered list of child IDs, with each child living once in the entity store — and to resolve those IDs into objects only at read time. This page is a concrete recipe within Relationship Stitching in Cache, and it leans on the same stable-key discipline covered in designing stable query keys for React Query: a join is only as reliable as the IDs it is keyed on.
The failure mode to avoid is embedding child objects directly inside the parent. The moment you do that, the same post exists in two places — the author’s embedded copy and the post detail cache — and they drift apart on the next edit. References keep one authoritative record and make the parent point at it.
Cache Behavior Analysis Checklist
Confirm your symptoms match a broken one-to-many join before restructuring:
- Editing a post updates the detail page but the author page still shows the old title.
- A newly created post appears after a refetch but not immediately in the author’s list.
- Deleting a post leaves a ghost row, or throws because a list still references the evicted ID.
- The same child object has two different references in DevTools depending on which query loaded it.
Step-by-Step Implementation
Step 1 — Store the relationship as an id list on the parent
Keep the parent’s children as an ordered array of IDs, and store each child once in a flat entity map. Order lives on the parent (the array), identity and fields live on the child.
// types/relations.ts
export interface Post {
id: string;
title: string;
authorId: string; // upward reference for post → author lookups
}
export interface Author {
id: string;
name: string;
postIds: string[]; // ordered downward references for author → posts
}
export interface EntityStore {
authors: Record<string, Author>;
posts: Record<string, Post>;
}
Cache Behavior Analysis. Because postIds is an array of primitives, appending or reordering is a cheap parent-only write that never touches the post records themselves. Storing authorId on each post as well gives you an O(1) reverse lookup without scanning every author. The two directions are redundant on purpose — that redundancy is what makes both read paths cheap, at the cost of updating both on mutation.
Step 2 — Resolve children by mapping the id list
Resolution is a read-time map from the id list against the entity store, guarded against dangling IDs. Never embed the resolved objects back into the parent.
// selectors/resolveAuthorPosts.ts
import type { EntityStore, Post } from '../types/relations';
export function resolveAuthorPosts(store: EntityStore, authorId: string): Post[] {
const author = store.authors[authorId];
if (!author) return [];
return author.postIds
.map((id) => store.posts[id]) // O(1) per id — no scan of the posts map
.filter((post): post is Post => post !== undefined); // drop dangling ids
}
Cache Behavior Analysis. Each store.posts[id] is a direct key access, so resolving N children is O(N) lookups rather than an O(authors × posts) scan. The filter type guard silently drops any id whose record was evicted, so a race between deleting a post and updating the parent list renders a shorter list instead of crashing. Wrap this in a memoized selector (keyed on author.postIds and store.posts) so it only recomputes when membership or a post actually changes.
Step 3 — Return References from an Apollo field policy
In Apollo, model the same relationship with a field read policy that returns References. This keeps every list pointing at the single normalized store slot for each post.
// apollo/cache.ts
import { InMemoryCache, Reference } from '@apollo/client';
export const cache = new InMemoryCache({
typePolicies: {
Author: {
fields: {
// Resolve the stored postIds into References at read time
posts: {
read(_existing, { readField, toReference }) {
const postIds = readField<readonly string[]>('postIds') ?? [];
return postIds
.map((id) => toReference({ __typename: 'Post', id }))
.filter((ref): ref is Reference => Boolean(ref));
},
},
},
},
},
});
Cache Behavior Analysis. toReference({ __typename: 'Post', id }) produces a pointer to the Post:id store slot without copying its fields, so a later scalar edit to that post propagates to every author list that references it. Because the read function returns References rather than embedded objects, Apollo can still apply the Post type’s own field policies (formatting, further relations) when each post is finally read. The stored postIds remain the single source of ordering.
Step 4 — Keep the join consistent across mutations
The one rule that prevents dangling references and ghost rows: update the entity and the join in the same cache write. On create, add the child and append its id. On delete, evict the child and remove its id.
// TanStack Query v5 — create keeps both sides in sync in one setQueryData
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useCreatePost(authorId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (draft: { title: string }) =>
fetch('/api/posts', { method: 'POST', body: JSON.stringify(draft) }).then((r) => r.json()),
onSuccess(newPost: { id: string; title: string; authorId: string }) {
queryClient.setQueryData<EntityStore>(['entities'], (store) => {
if (!store) return store;
return {
...store,
posts: { ...store.posts, [newPost.id]: newPost }, // write the entity
authors: {
...store.authors,
[authorId]: {
...store.authors[authorId],
postIds: [newPost.id, ...store.authors[authorId].postIds], // append the id
},
},
};
});
},
});
}
// Apollo — delete evicts the entity and cache.gc prunes the References
const [deletePost] = useMutation(DELETE_POST, {
update(cache, _result, { variables }) {
const id = cache.identify({ __typename: 'Post', id: variables!.id });
cache.evict({ id }); // remove the Post:id store slot
cache.gc(); // sweep now-unreferenced records; author.posts re-reads shorter
},
});
Cache Behavior Analysis. In the TanStack Query path, both the posts map and the parent’s postIds change in one setQueryData call, so the UI never observes a state where the id exists without its record or vice versa. In the Apollo path, cache.evict deletes the Post:id slot and cache.gc drops the now-dangling Reference; because Author.posts is a read policy over postIds, the next read simply skips the missing id — no manual list surgery needed. The complement to this is de-duplicating overlapping child lists across pages, covered in merging paginated lists without duplicates.
- Trade-offs: storing both
authorIdandpostIdsdoubles the write surface — every create/delete touches two slots — but halves read cost in both directions; pick one direction only if one read path is rare. Apolloreadpolicies run on every field access, so keep the resolve cheap and lean ontoReferencerather than rebuilding objects. In TanStack Query, setstructuralSharingon (the default) so unchanged posts keep their references across thesetQueryDataspread.
Edge Cases and Gotchas
Ordering that lives on the server
If the child order is server-controlled (e.g. by publishedAt), do not sort in the parent’s postIds client-side — you will fight the server on the next refetch. Store the server’s order in postIds verbatim and re-sort only in a derived selector if the view needs a different order.
Duplicate ids in the parent list
A retried create can append the same id twice, producing a duplicated row. Guard the append with a membership check, or de-duplicate the postIds array on write, so the join stays a set-like ordered list.
Cross-list membership after a move
Moving a post from one author to another must remove the id from the old parent and add it to the new one in the same write; updating only authorId on the post leaves the old author’s postIds pointing at a post that no longer belongs to them.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Editing a post updates its detail page but not the author’s list | The author embeds copied post objects instead of References/IDs, so the two copies drift | Store postIds on the author and resolve at read time, or return toReference from an Apollo read policy so both views share one slot |
| Deleting a post throws or leaves a ghost row | The post was evicted but the parent’s postIds still lists the dangling id |
Filter dangling ids in the resolver, and remove the id from postIds in the same write that evicts the child; run cache.gc() in Apollo |
| A newly created post does not appear until refetch | The mutation wrote the entity but never appended its id to the parent’s list | Append the id to postIds inside the same setQueryData/cache.modify that writes the entity |
| The same post shows different data in two lists | Two queries loaded the post independently and it was cached as separate copies | Ensure both queries select a stable id/__typename so normalization dedupes them to one record before resolution |
Frequently Asked Questions
Should the parent hold a list of child IDs, or should each child hold the parent ID?
Store the direction you read most. If you always render an author with their ordered posts, keep a postIds array on the author so order and membership are explicit. If you mostly render a post and only occasionally need its author, keep authorId on the post. Many apps store both — authorId for cheap upward lookups and postIds for ordered downward rendering — accepting that a mutation must update both sides in the same write.
Why return References from an Apollo field policy instead of embedding objects?
A Reference points at the single normalized store slot for that entity, so a scalar edit to a post propagates to every list referencing it automatically. Embedding the objects copies them, and copies drift apart on the next edit. Returning References from a read function also lets Apollo apply the child type’s own pagination, filtering, and field policies when each post is finally resolved.
How do I keep the author's post list correct after creating or deleting a post?
Update the join in the same cache write as the entity. On create, write the new post record and append its id (or Reference) to the parent’s list. On delete, evict the post and remove its id from the parent list in the same update so no dangling reference remains. In TanStack Query do both inside one setQueryData; in Apollo use cache.modify to append or cache.evict plus cache.gc to remove.
Related
- Relationship Stitching in Cache — the parent reference on joining entities across the store, covering many-to-many and circular relations too.
- Designing Stable Query Keys for React Query — the ID-contract discipline every reliable join depends on.
- Merging Paginated Lists Without Duplicates — how to keep a resolved child list clean when its pages overlap during infinite scroll.
- Data Normalization & Query Key Design — the top-level guide tying entity mapping, relationship stitching, and query key design into one cache model.