Skip to content

Commit 97f7735

Browse files
committed
docs: add Relay best practices for React
Add documentation covering: - Fragment colocation patterns - Container/Display split with Relay - Suspense boundaries - Query naming conventions - Mutations - Testing with provider-based DI
1 parent 6e1a366 commit 97f7735

2 files changed

Lines changed: 224 additions & 0 deletions

File tree

react/best-practices/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@
1919
theming and tokens with contrast/focus considerations.
2020
* [folder-structure.md](./folder-structure.md) — Project-specific layout
2121
guidance and feature public APIs (barrels).
22+
* [relay.md](./relay.md) — Relay GraphQL patterns: fragment colocation,
23+
container/display split, query naming, and testing.

react/best-practices/relay.md

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Relay
2+
3+
## Goals
4+
5+
* Colocate data requirements with components that use them
6+
* Separate data fetching from presentation
7+
* Enable efficient re-renders through fragment masking
8+
* Test GraphQL components without network mocks
9+
10+
## Fragment colocation
11+
12+
Each component declares its own data needs via fragments. The parent query
13+
spreads the fragment without knowing what fields are inside.
14+
15+
```typescript
16+
// TripCard.tsx — declares its own data requirements
17+
import { graphql, useFragment } from 'react-relay'
18+
import type { TripCard_trip$key } from './__generated__/TripCard_trip.graphql'
19+
20+
const tripFragment = graphql`
21+
fragment TripCard_trip on trip {
22+
id
23+
goals
24+
created_at
25+
}
26+
`
27+
28+
interface Props {
29+
trip: TripCard_trip$key
30+
onPress?: (tripId: string) => void
31+
}
32+
33+
export function TripCard({ trip: tripRef, onPress }: Props) {
34+
const trip = useFragment(tripFragment, tripRef)
35+
return <Card onPress={() => onPress?.(trip.id)}>{trip.goals?.[0]}</Card>
36+
}
37+
```
38+
39+
The parent spreads the fragment:
40+
41+
```typescript
42+
// TripListScreen.tsx — spreads child fragment
43+
const tripListQuery = graphql`
44+
query TripListScreenQuery {
45+
tripCollection {
46+
edges {
47+
node {
48+
nodeId
49+
...TripCard_trip
50+
}
51+
}
52+
}
53+
}
54+
`
55+
```
56+
57+
Benefits:
58+
59+
* Components are self-containedmove or delete without hunting for queries
60+
* Parent does not need to know child's data requirements
61+
* Relay masks fragment data, preventing accidental coupling
62+
63+
## Container/Display pattern with Relay
64+
65+
### Container component
66+
67+
Handles data fetching and business logic. Query is colocated with the container.
68+
69+
```typescript
70+
// TripListScreenContent.tsx
71+
export function TripListScreenContent() {
72+
const router = useRouter()
73+
const data = useLazyLoadQuery<TripListScreenQuery>(tripListQuery, {})
74+
75+
const handleViewTrip = (tripId: string) => {
76+
router.push(`/trips/${tripId}`)
77+
}
78+
79+
return (
80+
<YStack>
81+
{data.tripCollection?.edges?.map((edge) => (
82+
<TripCard
83+
key={edge.node.nodeId}
84+
trip={edge.node}
85+
onPress={handleViewTrip}
86+
/>
87+
))}
88+
</YStack>
89+
)
90+
}
91+
```
92+
93+
### Display component
94+
95+
Pure presentation via fragment. Does not know about routing or business logic.
96+
97+
```typescript
98+
// TripCard.tsx
99+
export function TripCard({ trip: tripRef, onPress }: Props) {
100+
const trip = useFragment(tripFragment, tripRef)
101+
return <Card onPress={() => onPress?.(trip.id)}>{trip.goals[0]}</Card>
102+
}
103+
```
104+
105+
## Suspense boundaries
106+
107+
Wrap containers in Suspense to handle loading state:
108+
109+
```typescript
110+
export function TripListScreen() {
111+
return (
112+
<Suspense fallback={<Spinner size="large" />}>
113+
<TripListScreenContent />
114+
</Suspense>
115+
)
116+
}
117+
```
118+
119+
Place boundaries close to the component that fetches data. This allows
120+
independent loading states for different parts of the UI.
121+
122+
## Query naming
123+
124+
Query names must match the filename. The Relay compiler enforces this.
125+
126+
```typescript
127+
// trip-list-screen.tsx
128+
const tripListScreenQuery = graphql`
129+
query tripListScreenQuery { ... } // matches filename
130+
`
131+
```
132+
133+
Fragment names follow the pattern `ComponentName_fieldName`:
134+
135+
```typescript
136+
// TripCard.tsx
137+
fragment TripCard_trip on trip { ... }
138+
```
139+
140+
## Mutations
141+
142+
```typescript
143+
import { graphql, useMutation } from 'react-relay'
144+
145+
const createTripMutation = graphql`
146+
mutation CreateTripMutation($input: tripInsertInput!) {
147+
insertIntotripCollection(objects: [$input]) {
148+
records {
149+
nodeId
150+
id
151+
goals
152+
}
153+
}
154+
}
155+
`
156+
157+
function useCreateTrip() {
158+
const [commit, isInFlight] = useMutation(createTripMutation)
159+
160+
const createTrip = (goals: string[]) => {
161+
commit({
162+
variables: { input: { goals } },
163+
onCompleted: (response) => { /* handle success */ },
164+
onError: (error) => { /* handle error */ },
165+
})
166+
}
167+
168+
return { createTrip, isCreating: isInFlight }
169+
}
170+
```
171+
172+
Include `nodeId` in mutation responses to enable automatic cache updates.
173+
174+
## Testing Relay components
175+
176+
Use provider-based dependency injection instead of `jest.mock()`:
177+
178+
```typescript
179+
import {
180+
TestRelayProvider,
181+
MockPayloadGenerator,
182+
createTestRelayEnvironment,
183+
} from '../test-utils/relay'
184+
185+
test('renders trip list', async () => {
186+
const environment = createTestRelayEnvironment()
187+
188+
render(
189+
<TestRelayProvider environment={environment}>
190+
<TripListScreen />
191+
</TestRelayProvider>
192+
)
193+
194+
// Resolve query with mock data
195+
await act(() => {
196+
environment.mock.resolveMostRecentOperation((operation) =>
197+
MockPayloadGenerator.generate(operation, {
198+
trip: () => ({
199+
nodeId: 'test-node-id',
200+
id: 'trip-123',
201+
goals: ['Visit Paris'],
202+
}),
203+
})
204+
)
205+
})
206+
207+
expect(screen.getByText('Visit Paris')).toBeTruthy()
208+
})
209+
```
210+
211+
This approach:
212+
213+
* Avoids `jest.mock()` which is a code smell
214+
* Uses the same provider pattern as production
215+
* Allows fine-grained control over mock responses
216+
217+
## Notes
218+
219+
* Query names must match filename (Relay compiler requirement)
220+
* See [component-structure](./component-structure.md) for general container/display
221+
patterns
222+
* See [testing](./testing.md) for dependency injection principles

0 commit comments

Comments
 (0)