Enforced Component Contracts
How data masking creates explicit interfaces between components
Enforced Component Contracts
Master enforced component contracts with free flashcards and spaced repetition practice. This lesson covers fragment composition patterns, data requirement enforcement, and contract validationโessential concepts for building maintainable Relay applications that prevent data-fetching bugs at compile time.
Welcome to Component Data Contracts ๐ป
Welcome to one of Relay's most powerful features! In traditional data-fetching approaches, components often make assumptions about what data will be available, leading to runtime errors when those assumptions break. Relay solves this with enforced component contractsโa system that guarantees every component receives exactly the data it declares, verified at build time.
Think of it like TypeScript for your data requirements. Just as TypeScript catches type errors before runtime, Relay catches data requirement mismatches before your code ever runs. This lesson will show you how Relay enforces these contracts and why this enforcement is revolutionary for building reliable applications.
What Are Component Contracts? ๐ค
A component contract is a formal declaration of what data a component needs to render correctly. In Relay, every component that needs data declares its requirements using a GraphQL fragment. This fragment becomes a binding contract that Relay enforces throughout your application.
Here's the key insight: You cannot render a component without satisfying its data contract. Relay makes this physically impossible through its compiler and type system.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ TRADITIONAL APPROACH โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค โ Component expects props.user.email โ โ โ โ โ Parent passes incomplete data โ โ โ โ โ ๐ฅ Runtime error: Cannot read 'email' โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ RELAY APPROACH โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค โ Component declares fragment โ โ โ โ โ Parent MUST include child fragment โ โ โ โ โ Relay compiler verifies at build time โ โ โ โ โ โ Runtime safety guaranteed โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Three Pillars of Enforcement ๐๏ธ
Relay enforces component contracts through three mechanisms working together:
1. Fragment Composition (Compile-Time)
When a parent component renders a child, it must compose the child's fragment into its own query. The Relay compiler verifies this composition is complete.
2. Type System Integration (Development-Time)
Relay generates TypeScript types from your fragments. If you try to render a component without spreading its fragment, TypeScript will show an error before you even run the code.
3. Runtime Validation (Production Safety)
Even if something slips through (rare), Relay includes runtime checks that prevent components from receiving incomplete data.
๐ก Pro Tip: These three layers create a "defense in depth" strategy. Most errors are caught at compile time, some at development time, and the rest at runtimeโbut well before users see broken UI.
Fragment Spreading: The Enforcement Mechanism ๐
The syntax that enforces contracts is fragment spreading. When you write ...UserProfile_user, you're doing more than including fieldsโyou're establishing a formal contract:
fragment ParentComponent_data on Query {
user {
id
...UserProfile_user # Contract enforcement point
...UserSettings_user # Another contract
}
}
What Relay enforces here:
- Completeness: All fragments must be spread
- Correctness: Fragments must be spread on compatible types
- Isolation: Children cannot access parent data without explicit passing
| Enforcement Check | What It Prevents | When It Runs |
|---|---|---|
| Fragment matching | Spreading fragments on wrong types | Compile time |
| Missing spreads | Rendering child without its data | TypeScript/Compile |
| Data masking | Accessing undeclared fields | Runtime |
| Type compatibility | Type mismatches in fragment refs | TypeScript |
How the Compiler Enforces Contracts ๐
The Relay compiler (relay-compiler) is the primary enforcer. When you run it, here's what happens:
Step 1: Parse all fragments
The compiler scans your codebase and extracts every GraphQL fragment and query.
Step 2: Build a dependency graph
It maps which components depend on which data, creating a complete picture of your data requirements.
Step 3: Verify composition
For every component render, it checks that parent queries include all child fragments.
Step 4: Generate types
It creates TypeScript types that make violations impossible to write.
COMPILER VERIFICATION FLOW
๐ Source Code
|
โ
๐ Extract Fragments
|
โ
๐ Build Dependency Graph
|
โ
โ
Verify Composition Rules
|
โโโโ โ Missing Spread โ Compilation Error
โโโโ โ Type Mismatch โ Compilation Error
โโโโ โ Unused Fragment โ Warning
|
โ
๐ Generate TypeScript Types
|
โ
๐พ Output Compiled Artifacts
Type-Safe Fragment References ๐ฆ
Relay generates a special type for each fragment called a fragment reference (or "fragment ref"). This type is opaqueโyou cannot inspect its properties directly. You can only pass it to useFragment() to "unwrap" it.
This opacity is intentional enforcement:
// Generated by Relay compiler
type UserProfile_user$key = {
readonly " $data"?: UserProfile_user$data;
readonly " $fragmentSpreads": FragmentRefs<"UserProfile_user">;
};
// You cannot do this:
function UserProfile(props: { user: UserProfile_user$key }) {
const name = props.user.name; // โ TypeScript error!
// The type is opaqueโyou can't access fields directly
}
// You must do this:
function UserProfile(props: { user: UserProfile_user$key }) {
const data = useFragment(graphql`...`, props.user); // โ
const name = data.name; // Now it works!
}
Why opaque types? They enforce that you cannot access data you didn't declare. This is data masking at the type level.
Contract Violations and Error Messages ๐จ
When you violate a contract, Relay gives clear, actionable errors. Let's see common violations:
Violation 1: Forgetting to Spread a Fragment
fragment ParentComponent_data on Query {
user {
id
# Missing: ...UserProfile_user
}
}
function ParentComponent({ data }) {
return <UserProfile user={data.user} />; // โ
}
Error message:
Type 'User' is not assignable to type 'UserProfile_user$key'.
Property ' $fragmentSpreads' is missing in type 'User'.
Fix: Add the fragment spread:
fragment ParentComponent_data on Query {
user {
id
...UserProfile_user // โ
}
}
Violation 2: Spreading on Wrong Type
fragment UserProfile_user on User {
name
email
}
fragment ParentComponent_data on Query {
organization {
...UserProfile_user # โ Organization โ User
}
}
Error message:
Fragment 'UserProfile_user' cannot be spread here as objects of type
'Organization' can never be of type 'User'.
Violation 3: Missing useFragment Call
// Trying to use fragment ref directly
function UserProfile({ user }: { user: UserProfile_user$key }) {
return <div>{user.name}</div>; // โ user is opaque!
}
Error message:
Property 'name' does not exist on type 'UserProfile_user$key'.
Fix: Use the hook:
function UserProfile({ user }: { user: UserProfile_user$key }) {
const data = useFragment(
graphql`fragment UserProfile_user on User {
name
}`,
user
);
return <div>{data.name}</div>; // โ
}
Example 1: Simple Contract Enforcement ๐
Let's build a user profile display with proper contract enforcement:
Child Component (UserAvatar.tsx):
import { graphql, useFragment } from 'react-relay';
import type { UserAvatar_user$key } from './__generated__/UserAvatar_user.graphql';
interface Props {
user: UserAvatar_user$key; // Fragment reference type
}
export default function UserAvatar({ user }: Props) {
const data = useFragment(
graphql`
fragment UserAvatar_user on User {
name
avatarUrl
}
`,
user
);
return (
<div className="avatar">
<img src={data.avatarUrl} alt={data.name} />
<span>{data.name}</span>
</div>
);
}
Parent Component (UserProfile.tsx):
import { graphql, useFragment } from 'react-relay';
import UserAvatar from './UserAvatar';
import type { UserProfile_query$key } from './__generated__/UserProfile_query.graphql';
interface Props {
queryRef: UserProfile_query$key;
}
export default function UserProfile({ queryRef }: Props) {
const data = useFragment(
graphql`
fragment UserProfile_query on Query {
currentUser {
id
email
...UserAvatar_user # โ
Spreading child's contract
}
}
`,
queryRef
);
return (
<div>
<UserAvatar user={data.currentUser} /> {/* โ
Contract satisfied */}
<p>Email: {data.currentUser.email}</p>
</div>
);
}
What's enforced here:
UserProfileMUST spread...UserAvatar_userto renderUserAvatar- TypeScript prevents passing
data.currentUserwithout the fragment spread UserAvatarcan ONLY accessnameandavatarUrl(data masking)- If
UserAvataradds a field to its fragment,UserProfileautomatically includes it
Example 2: Multiple Child Contracts ๐
Handling multiple children with different data requirements:
Children:
// UserBasicInfo.tsx
function UserBasicInfo({ user }: { user: UserBasicInfo_user$key }) {
const data = useFragment(
graphql`
fragment UserBasicInfo_user on User {
name
email
phoneNumber
}
`,
user
);
// ... render basic info
}
// UserPreferences.tsx
function UserPreferences({ user }: { user: UserPreferences_user$key }) {
const data = useFragment(
graphql`
fragment UserPreferences_user on User {
theme
language
notifications {
email
push
}
}
`,
user
);
// ... render preferences
}
// UserActivity.tsx
function UserActivity({ user }: { user: UserActivity_user$key }) {
const data = useFragment(
graphql`
fragment UserActivity_user on User {
lastLogin
loginCount
recentActions {
type
timestamp
}
}
`,
user
);
// ... render activity
}
Parent:
function UserDashboard({ queryRef }: { queryRef: UserDashboard_query$key }) {
const data = useFragment(
graphql`
fragment UserDashboard_query on Query {
viewer {
id
# All child contracts must be satisfied
...UserBasicInfo_user
...UserPreferences_user
...UserActivity_user
}
}
`,
queryRef
);
return (
<div className="dashboard">
<UserBasicInfo user={data.viewer} />
<UserPreferences user={data.viewer} />
<UserActivity user={data.viewer} />
</div>
);
}
Enforcement guarantee: If you forget even ONE fragment spread, the code won't compile. Try removing ...UserActivity_user and TypeScript will immediately show an error when you try to pass data.viewer to UserActivity.
Example 3: Conditional Rendering with Contracts โ๏ธ
Contracts work even with conditional rendering:
function UserSettings({ queryRef }: { queryRef: UserSettings_query$key }) {
const data = useFragment(
graphql`
fragment UserSettings_query on Query {
viewer {
id
isPremium
...BasicSettings_user
...PremiumSettings_user # Always spread, even if conditional
}
}
`,
queryRef
);
return (
<div>
<BasicSettings user={data.viewer} />
{/* Conditional rendering is fineโdata is always fetched */}
{data.viewer.isPremium && (
<PremiumSettings user={data.viewer} />
)}
</div>
);
}
Key insight: You spread all fragments needed by potentially rendered components. Relay fetches the data upfront, making conditional rendering fast (no loading states).
๐ก Pattern: Spread fragments based on what CAN render, not what WILL render. This eliminates loading states from conditional UI.
Example 4: List Rendering with Contracts ๐
Enforcing contracts for collections:
// ListItem component
function TodoItem({ todo }: { todo: TodoItem_todo$key }) {
const data = useFragment(
graphql`
fragment TodoItem_todo on Todo {
id
title
completed
dueDate
}
`,
todo
);
return (
<li>
<input type="checkbox" checked={data.completed} />
<span>{data.title}</span>
<span>{data.dueDate}</span>
</li>
);
}
// List component
function TodoList({ queryRef }: { queryRef: TodoList_query$key }) {
const data = useFragment(
graphql`
fragment TodoList_query on Query {
todos {
id
...TodoItem_todo # Spread for each item
}
}
`,
queryRef
);
return (
<ul>
{data.todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
Enforcement: Each todo in the array has the fragment spread, so each TodoItem receives valid data. If you forget the spread, TypeScript prevents the entire list from compiling.
The Contract Verification Algorithm ๐งฎ
Here's how Relay verifies contracts (simplified):
FOR each component that calls useFragment(fragment, ref):
1. Find the parent query/fragment that provides 'ref'
2. Check if parent spreads 'fragment' on the same object
3. IF NOT found:
โ Compilation error: Missing fragment spread
4. IF found on wrong type:
โ Compilation error: Type incompatibility
5. IF found correctly:
โ Generate type-safe fragment reference
โ Mark contract as satisfied โ
FOR each fragment spread ...Fragment_name:
1. Verify Fragment_name exists
2. Verify spread is on compatible type
3. Track in dependency graph
FOR each query root:
1. Build complete dependency tree
2. Verify all leaf fragments are included
3. Generate single optimized query
Benefits of Enforced Contracts ๐
1. No Runtime Data Errors
You cannot access fields you didn't declare. data.user.email either works (because you declared it) or doesn't compile (because you didn't).
2. Refactoring Safety
Change a child's fragment? The compiler tells you every parent that needs updating.
3. Automatic Optimization
Relay deduplicates fields across fragments automatically. Multiple children asking for user.name results in one field in the query.
4. Clear Ownership
Every field in your query is owned by exactly one component. No confusion about who needs what.
5. Documentation as Code
Fragments serve as machine-verified documentation of component dependencies.
| Benefit | Traditional Approach | Relay Contracts |
|---|---|---|
| Error detection | Runtime (production) | Compile time |
| Refactoring confidence | Low (manual checks) | High (automated) |
| Performance optimization | Manual deduplication | Automatic |
| Documentation accuracy | Often outdated | Always accurate |
| Onboarding time | High (implicit patterns) | Lower (explicit contracts) |
Contract Enforcement vs Data Masking ๐ญ
These concepts work together:
- Contract Enforcement: Ensures you fetch all required data
- Data Masking: Ensures you only access declared data
Think of them as two sides of the same coin:
CONTRACT ENFORCEMENT
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Parent must spread โ
โ child's fragment โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Data is fetched โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Fragment ref passed โ
โ to child โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ
DATA MASKING
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Child can only access โ
โ fields it declared โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
Together they guarantee: Every component gets exactly what it needs, nothing more, nothing less.
Common Mistakes โ ๏ธ
Mistake 1: Trying to Access Raw Data
// โ WRONG
function UserProfile({ user }) {
// Trying to access fields directly from fragment ref
return <div>{user.name}</div>; // Error: Property 'name' doesn't exist
}
// โ
CORRECT
function UserProfile({ user }) {
const data = useFragment(graphql`...`, user);
return <div>{data.name}</div>;
}
Why it fails: Fragment references are opaque types. You must unwrap them with useFragment().
Mistake 2: Forgetting to Spread in Conditionals
// โ WRONG
fragment Parent_data on Query {
user {
isPremium
# Missing: ...PremiumFeatures_user
}
}
function Parent({ data }) {
return data.user.isPremium ? (
<PremiumFeatures user={data.user} /> // Error!
) : null;
}
// โ
CORRECT
fragment Parent_data on Query {
user {
isPremium
...PremiumFeatures_user # Always spread
}
}
Why it fails: Fragments must be spread even for conditional rendering. The data is fetched upfront.
Mistake 3: Spreading on Wrong Type
// โ WRONG
fragment UserDetails_user on User {
name
}
fragment Parent_data on Query {
organization {
...UserDetails_user // Error: Organization is not User!
}
}
// โ
CORRECT: Create appropriate fragment
fragment OrganizationDetails_org on Organization {
name
}
Why it fails: Fragments are type-specific. You cannot spread a User fragment on an Organization.
Mistake 4: Circular Fragment Dependencies
// โ WRONG
fragment UserProfile_user on User {
friends {
...FriendList_users
}
}
fragment FriendList_users on User {
id
...UserProfile_user // Circular!
}
Why it fails: Circular dependencies create infinite queries. Use pagination or separate fragments for different contexts.
Mistake 5: Not Running Relay Compiler
// You write this...
fragment MyComponent_data on User {
newField // Just added
}
// But forget to run: relay-compiler
// Result: TypeScript types are outdated, errors appear
Why it fails: Relay generates types at build time. Changes to fragments require rerunning the compiler.
๐ก Solution: Set up relay-compiler --watch in development to auto-regenerate types.
Mistake 6: Assuming Parent Can Access Child Fields
// โ WRONG ASSUMPTION
fragment Parent_data on Query {
user {
...ChildComponent_user # Child declares 'email'
}
}
function Parent({ data }) {
const childData = useFragment(childFragment, data.user);
// Parent thinking: "Child fetched email, so I can use it!"
console.log(data.user.email); // โ undefined! Data masking prevents this
}
// โ
CORRECT: Declare fields you need
fragment Parent_data on Query {
user {
email # Parent declares its own needs
...ChildComponent_user
}
}
Why it fails: Data masking ensures you can only access fields you explicitly declare, even if children declare them.
Debugging Contract Violations ๐ง
When you encounter contract errors, follow this process:
Step 1: Read the Error Message
Relay's errors are specific. They tell you:
- Which fragment is missing
- Where it should be spread
- What type mismatch exists
Step 2: Trace the Component Tree
QueryComponent
โโ> ParentComponent โ Forgot to spread fragment?
โโ> ChildComponent โ Error appears here
Step 3: Check the Fragment Spread
Look at the parent's fragment:
fragment ParentComponent_data on Query {
user {
# Is ...ChildComponent_user here?
}
}
Step 4: Verify Type Compatibility
Check that you're spreading on the correct type:
fragment ChildComponent_user on User { ... } # Expects User
fragment Parent_data on Query {
user { # โ
This is User type
...ChildComponent_user
}
}
Step 5: Regenerate Types
Run relay-compiler to ensure types are current:
npm run relay # or yarn relay
Advanced Pattern: Fragment Composition Chains ๐
Fragments can compose other fragments, creating chains:
// Level 3: Leaf component
fragment Avatar_user on User {
avatarUrl
}
// Level 2: Intermediate component
fragment UserCard_user on User {
name
...Avatar_user // Composes leaf
}
// Level 1: Parent component
fragment UserList_query on Query {
users {
id
...UserCard_user // Composes intermediate
}
}
// Level 0: Root query
query AppQuery {
...UserList_query // Composes parent
}
What Relay enforces:
- Each level must spread the level below
- The final query includes all leaf fragments
- Changes to leaf propagate through the chain
- Types are verified at each level
Result: One optimized query that includes all fields from all fragments:
query AppQuery {
users {
id
name
avatarUrl
}
}
Testing Components with Contracts ๐งช
Relay's enforcement extends to testing. Use MockPayloadGenerator to create test data that satisfies contracts:
import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils';
test('UserProfile renders correctly', () => {
const environment = createMockEnvironment();
const { getByText } = render(
<RelayEnvironmentProvider environment={environment}>
<UserProfile queryRef={...} />
</RelayEnvironmentProvider>
);
// Relay ensures mock data matches fragment contract
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
User: () => ({
name: 'Test User',
email: 'test@example.com',
}),
})
);
expect(getByText('Test User')).toBeInTheDocument();
});
The contract ensures: Mock data structure matches what the component expects. If the fragment changes, tests update automatically.
Key Takeaways ๐ฏ
Contract = Fragment Declaration: Every component declares its data needs as a GraphQL fragment, creating a binding contract.
Enforcement = Fragment Spreading: Parents must spread child fragments to render children. This is verified at compile time.
Three-Layer Defense: Compile-time (Relay compiler), development-time (TypeScript), and runtime (data masking) all enforce contracts.
Opaque Fragment Refs: Generated types prevent accessing data without
useFragment(), enforcing proper contract usage.Type Safety Throughout: Relay generates TypeScript types that make contract violations impossible to write.
Benefits Everywhere: No runtime errors, safe refactoring, automatic optimization, clear ownership, self-documenting code.
Always Spread for Conditionals: Even conditionally rendered components need their fragments spread upfront.
Data Masking + Contracts: Together they guarantee components receive exactlyโand onlyโwhat they declare.
๐ Quick Reference Card
| Concept | Implementation |
|---|---|
| Declare contract | fragment Component_data on Type { fields } |
| Enforce contract | Spread in parent: ...Component_data |
| Use contract data | useFragment(graphql`...`, fragmentRef) |
| Fragment ref type | Component_data$key (opaque) |
| Verify contracts | relay-compiler + TypeScript |
| Multiple children | Spread all: ...Child1 ...Child2 ...Child3 |
| Conditional render | Always spread, conditionally render component |
| Lists | Spread on list item type, map with fragment ref |
Remember: Fragment spreading is not just syntaxโit's the mechanism that makes Relay's guarantees possible. Every spread is a compile-time verified contract that prevents entire classes of bugs.
๐ Further Study
- Relay Fragment Container Documentation - Official guide to
useFragmentand fragment composition patterns - Relay Compiler Architecture - Deep dive into how Relay enforces contracts at build time
- Type-Safe GraphQL with Relay - Understanding Relay's TypeScript integration and generated types