Using fragments for type-safe data sharing and component composition
Fragments capture the data contract for a component. Think of them as the schema that a component owns, rather than a shared utility to sprinkle everywhere. Our approach uses direct fragment type access—no runtime masking, just compile-time type safety scoped to the component consuming the fragment.
Fragments build on operations to provide type-safe, reusable field selections. They capture the data contract for a component and generate TypeScript types you can use directly in props.
We use fragments to generate TypeScript types that can be used directly in component props:
This generates a TypeScript type that you import and use directly:
Create a fragment file next to your component:
Import and use the fragment in your operations:
For fragments used by a single component:
Fragment composition is the core pattern for scaling a fragmentized component tree. Instead of one large fragment that owns every field, break the fragment into focused child fragments—one per logical concern—and compose them back into the parent via ...Spread. A single query at the route level fetches all the data, and typed props flow downward through the component tree.
Define each child fragment in the file closest to the component that consumes it, then spread it into the parent fragment:
Child components receive their data via typed props—they do not make their own useQuery calls:
When a shared fragment grows too large, the right approach is to extract focused child fragments and compose them back—not to move fields into separate queries. This improves ownership and readability while preserving the single-query model. (It does not reduce payload by itself unless route-level queries stop spreading unused child fragments.) See the Query Splitting anti-pattern below for what to avoid.
...ChildFragment back into the parent fragmentProblem: Drilling into query types without fragments
Issues:
Problem: Creating types manually or using assertions
Solution: Use generated fragment types
Problem: Removing fields from a fragment and replacing them with separate useQuery calls in each component
When a fragment feels too large, it's tempting to move individual fields into dedicated queries so each component fetches only what it needs. This is the query splitting anti-pattern:
Issues:
chatroom(id:) runs once per querychatroom(id:) are each a distinct requestSolution: Extract focused child fragments and compose them back into the parent—keep a single query at the route level
A common anti-pattern is extracting types directly from query results using TypeScript's indexed access types. This creates fragile, hard-to-maintain code that breaks when query structure changes.
Problem: Extracting types from query results using indexed access
Issues:
Better, but not ideal: Using NonNullable to handle nullable query results
Benefits over raw extraction:
Limitations:
NonNullable calls become hard to readWhen to use: This is acceptable as a temporary step during migration, but you should still migrate to fragments for the full benefits.
Solution: Create fragments and use generated fragment types
Create fragment files next to your components or in a shared fragments directory:
Update your query to use the fragments instead of inline fields:
Replace query type extractions with fragment types:
Components now use the fragment types directly:
After migrating to fragments:
AudioCollectorDeviceData vs Query["dispatchCenter"]["audioCollectorStatusReports"][0])When you add or remove fields from a fragment:
UserCardData, ChatroomListItem...FragmentName in queriesuseQuery calls per component (see Query Splitting anti-pattern above)Use fragments with inline fragments for polymorphic types:
When working with lists:
When list items re-render too broadly because the parent passes new object references on every Apollo cache update (for example after a subscription writes many normalized entities), see Reading fragments from cache with useFragment below.
Apollo Client’s useFragment reads a specific fragment for an entity already in the normalized cache. It creates a cache subscription: the component re-renders only when fields in that fragment change for that id, not when unrelated cache keys update. See the Fragments guide for Apollo’s full documentation on the hook.
Use this pattern for list rows, map markers, or any repeated UI where the parent maps query edges to children and would otherwise pass full objects—so every child gets a new reference whenever the parent re-renders after a wide cache write.
When to use
Chatroom, etc.).CommunicationsListUpdated) update the cache for many chatrooms at once.React.memo on the row/marker to skip re-renders unless that row’s fragment data actually changed.Pattern
chatroomId={listItem.id}) plus route props (to, active), not the full chatroom object.useFragment with from: { __typename: "Chatroom", id: chatroomId } and the generated *FragmentDoc that matches what list queries already write (e.g. ChatroomListItemDataFragmentDoc, ChatroomMapFragmentDoc).null (or a skeleton) from the shell.React.memo) receives the resolved fragment data as props and runs other hooks (useShareIndicators, etc.).Shell / body split (rules of hooks)
You cannot call hooks after an early return. So split the component:
useFragment (and optionally nothing else), then guard on if (!complete || !data?.id) return null, then render the body.memo’d; all remaining hooks live here.Incorrect (every row re-renders when the parent’s mapped data gets new references)
Correct (stable chatroomId + useFragment + memo)
Map markers
The same idea applies to pins that previously received a full ChatroomMap object from useChatroomMapList: pass chatroomId, read ChatroomMapFragmentDoc in a shell, render a memoized body that builds the marker and tooltip.
Typing
useFragment returns { complete, data, missing }. The complete boolean tells you whether every field in the fragment was found in cache. When complete is false, data may contain partial fields (MaybeMasked<TData> | DataValue.Partial<MaybeMasked<TData>> in Apollo’s types). Guard with if (!complete || !data?.id) return null, then cast to your concrete fragment type (e.g. data as ChatroomListItemData) so downstream code stays strict.
How this fits the rest of the doc
useFragment when cache churn causes unnecessary re-renders.BroadcastableChatroomListData and list composition; subscription payloads often merge into those fields—useFragment rows subscribe to the merged cache slice per id.ChatroomEventCreated in ChatroomContextProvider) reduces duplicate WebSocket work; useFragment on list rows reduces React re-render blast radius when list cache updates still fire often. The two are complementary.From our codebase, here's how the chatroom list uses fragments:
This fragment is then composed in other fragments:
And used in queries:
Always import from .graphql files (Vite resolves to .graphql.ts automatically):
Ensure fragment names are unique across your application. Use descriptive names that include the component context:
For day-to-day development workflow, debugging, and troubleshooting, see the Development Workflow guide.
On this page