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.

Resolving an author's posts from an id-reference list An author record on the left holds an ordered array of post ids. A resolve step in the middle maps each id to its record in the flat posts store. The output on the right is the author's ordered list of resolved post objects, and a note shows a new post being appended to both the store and the id list. Author:u9 (parent) name: "Ada" postIds (ordered) [ p3, p1, p7 ] references, not copies resolve postIds.map(id => posts[id]) O(1) per id lookup posts store (flat) p1 { title: "Notes" } p3 { title: "Engine" } p7 { title: "Looms" } one record per post resolved list (author view) Engine · Notes · Looms Create post p9 — one atomic write 1) write posts[p9] 2) append p9 to Author:u9.postIds → both sides stay consistent
The author holds an ordered id list; resolution maps each id to its one store record. Creating a post writes the entity and appends its id in the same update so the join never dangles.

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 authorId and postIds doubles 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. Apollo read policies run on every field access, so keep the resolve cheap and lean on toReference rather than rebuilding objects. In TanStack Query, set structuralSharing on (the default) so unchanged posts keep their references across the setQueryData spread.

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.