Parent-Child Data Boundaries
Why parents cannot reach into child fragment data and what this prevents
Parent-Child Data Boundaries
Master parent-child data boundaries in Relay with free flashcards and spaced repetition practice. This lesson covers data isolation strategies, boundary enforcement patterns, and access control mechanismsβessential concepts for building secure, maintainable GraphQL applications with Relay's fragment-based architecture.
Welcome to Data Masking Fundamentals
π» In Relay's fragment architecture, parent-child data boundaries define how data flows (or doesn't flow) between components in your React tree. Unlike traditional prop drilling or global state patterns, Relay enforces data maskingβa parent component cannot access data declared in a child's fragment, and vice versa. This architectural decision prevents tight coupling and creates clear contracts between components.
Think of it like privacy walls between apartments in a building. Each tenant (component) has access only to their own space (declared fragments), even though they're all part of the same structure (component tree). The landlord (Relay) enforces these boundaries automatically.
Core Concepts
π What Are Data Boundaries?
A data boundary is the invisible barrier that prevents a component from accessing fragment data declared by its ancestors or descendants. When you declare a fragment in a component, that data is masked from all other componentsβthey see only an opaque reference, not the actual data.
βββββββββββββββββββββββββββββββββββββββ
β ParentComponent β
β fragment Parent on User { β
β id β
β name β Parent can access β
β } β
β βββββββββββββββββββββββββββββββββ β
β β ChildComponent β β
β β fragment Child on User { β β
β β email β Child only β β
β β phone β β
β β } β β
β βββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββ
Parent CANNOT access email/phone
Child CANNOT access name
π― Why Boundaries Matter
1. Prevents Coupling π
Without boundaries, changing a child's data requirements could break parent components that depended on that data. With masking, each component's data contract is explicit and isolated.
2. Enables Refactoring π
You can move components around the tree or change their internal data needs without cascading changes throughout your codebase.
3. Improves Performance β‘
Relay can optimize re-renders because it knows exactly which components need updates when specific data changes.
4. Enforces Encapsulation π¦
Components become true black boxesβtheir implementation details (including data needs) are hidden from the outside world.
π‘οΈ How Relay Enforces Boundaries
Relay uses opaque fragment references at runtime. When you spread a fragment, you receive a reference object that's meaningless without the corresponding useFragment hook:
// ParentComponent.jsx
import {graphql, useFragment} from 'react-relay';
function ParentComponent({queryRef}) {
const data = useFragment(
graphql`
fragment ParentComponent_user on User {
id
name
...ChildComponent_user # Spread child's fragment
}
`,
queryRef
);
console.log(data.name); // β
Works - declared in Parent
console.log(data.email); // β Undefined - declared in Child
// data.ChildComponent_user is an opaque reference
return (
<div>
<h1>{data.name}</h1>
<ChildComponent user={data} /> {/* Pass the reference */}
</div>
);
}
// ChildComponent.jsx
function ChildComponent({user}) {
const data = useFragment(
graphql`
fragment ChildComponent_user on User {
email
phone
}
`,
user // Unwrap the reference here
);
console.log(data.email); // β
Works - declared in Child
console.log(data.name); // β Undefined - declared in Parent
return <div>{data.email}</div>;
}
Key insight: The parent spreads ...ChildComponent_user but receives only an opaque reference. The child must call useFragment with that reference to "unlock" the actual data.
ποΈ Boundary Patterns in Practice
Pattern 1: Strict Parent-Child Isolation
The most common patternβeach component declares exactly the data it needs, nothing more:
ββββββββββββββββββββββββββββββββββ
β ProfilePage β
β fragment ProfilePage { β
β viewer { id } β
β ...ProfileHeader_viewer β
β ...ProfileContent_viewer β
β } β
β ββββββββββββββββββββββββββββ β
β β ProfileHeader β β
β β fragment { avatar, name }β β
β ββββββββββββββββββββββββββββ β
β ββββββββββββββββββββββββββββ β
β β ProfileContent β β
β β fragment { bio, posts } β β
β ββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββ
Benefits:
- Each component is independently testable
- No shared data dependencies
- Clear ownership boundaries
Pattern 2: Shared Data with Duplication
When multiple components need the same field, they each declare it independently:
// Parent needs user ID for analytics
fragment Parent_user on User {
id # Declared here
name
...Child_user
}
// Child also needs user ID for routing
fragment Child_user on User {
id # Declared again - that's OK!
email
}
π‘ Relay deduplicates these requests automaticallyβthe network fetches id only once, even though it appears in multiple fragments.
Pattern 3: Coordinated Boundaries with @arguments
Sometimes a parent needs to influence a child's data fetching without accessing the data itself:
// Parent controls pagination size
fragment Parent_user on User {
id
...PostList_user @arguments(count: 10)
}
// Child receives the argument
fragment PostList_user on User
@argumentDefinitions(count: {type: "Int!", defaultValue: 5}) {
posts(first: $count) {
edges { node { id, title } }
}
}
The parent sets count: 10 but never sees the posts dataβthe boundary remains intact.
β οΈ Boundary Violations and Anti-Patterns
Anti-Pattern 1: Prop Drilling Fragment Data
β Wrong approach:
function Parent({queryRef}) {
const data = useFragment(parentFragment, queryRef);
const childData = useFragment(childFragment, data); // WRONG!
return <Child data={childData} />; // Breaking encapsulation
}
β Correct approach:
function Parent({queryRef}) {
const data = useFragment(parentFragment, queryRef);
return <Child userRef={data} />; // Pass opaque reference
}
function Child({userRef}) {
const data = useFragment(childFragment, userRef); // Child unwraps
return <div>{data.email}</div>;
}
Anti-Pattern 2: Accessing Masked Fields
β Wrong:
fragment Parent_user on User {
...Child_user
}
// Later in component
const {email} = data; // Undefined! Not declared in Parent
β Correct:
fragment Parent_user on User {
email // Explicitly declare if Parent needs it
...Child_user
}
Anti-Pattern 3: Over-Fetching in Parents
β Wrong:
// Parent fetches everything "just in case"
fragment Parent_user on User {
id
name
email
phone
address
bio
...Child_user
}
β Correct:
// Parent fetches only what it renders directly
fragment Parent_user on User {
id
name // Only these two used in Parent's render
...Child_user // Child declares its own needs
}
Detailed Examples
Example 1: Dashboard with Multiple Widgets
Scenario: A dashboard page displays user info in a header, activity feed in the main area, and settings panel in a sidebar. Each widget should manage its own data.
// DashboardPage.jsx
import {graphql, useLazyLoadQuery} from 'react-relay';
function DashboardPage() {
const data = useLazyLoadQuery(
graphql`
query DashboardPageQuery {
viewer {
id # Dashboard needs ID for analytics
...DashboardHeader_user
...ActivityFeed_user
...SettingsPanel_user
}
}
`,
{}
);
// Dashboard CANNOT access name, email, preferences, etc.
// It only knows the viewer's ID and has opaque references
console.log(data.viewer.id); // β
Works
console.log(data.viewer.name); // β undefined
return (
<div className="dashboard">
<DashboardHeader user={data.viewer} />
<main>
<ActivityFeed user={data.viewer} />
</main>
<aside>
<SettingsPanel user={data.viewer} />
</aside>
</div>
);
}
// DashboardHeader.jsx
function DashboardHeader({user}) {
const data = useFragment(
graphql`
fragment DashboardHeader_user on User {
name
avatar
memberSince
}
`,
user
);
return (
<header>
<img src={data.avatar} alt={data.name} />
<h1>Welcome, {data.name}</h1>
<span>Member since {data.memberSince}</span>
</header>
);
}
// ActivityFeed.jsx
function ActivityFeed({user}) {
const data = useFragment(
graphql`
fragment ActivityFeed_user on User {
recentActivity(first: 10) {
edges {
node {
id
type
timestamp
description
}
}
}
}
`,
user
);
return (
<ul>
{data.recentActivity.edges.map(({node}) => (
<li key={node.id}>
{node.description} - {node.timestamp}
</li>
))}
</ul>
);
}
// SettingsPanel.jsx
function SettingsPanel({user}) {
const data = useFragment(
graphql`
fragment SettingsPanel_user on User {
email
preferences {
notifications
theme
language
}
}
`,
user
);
return (
<div>
<h3>Settings</h3>
<p>Email: {data.email}</p>
<label>
<input
type="checkbox"
checked={data.preferences.notifications}
/>
Notifications
</label>
{/* More settings... */}
</div>
);
}
Data Flow Diagram:
βββββββββββββββββββββββββββββββββββββββββββββββ
β DashboardPage Query β
β viewer { id } β
β β
β ββββββββββββββββββββ β
β β DashboardHeader β β
β β name, avatar β β
β β memberSince β β
β ββββββββββββββββββββ β
β β
β ββββββββββββββββββββ β
β β ActivityFeed β β
β β recentActivity { β β
β β type, time β β
β β } β β
β ββββββββββββββββββββ β
β β
β ββββββββββββββββββββ β
β β SettingsPanel β β
β β email, β β
β β preferences β β
β ββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
Each widget is isolated - changes to one
don't affect others
Why this works:
- DashboardPage doesn't know or care what data each widget needs
- Widgets can be reordered, removed, or replaced without touching parent code
- Each widget can evolve independently (e.g., ActivityFeed adds filtering)
- Testing is simple: mock the fragment reference for each widget in isolation
Example 2: Nested Comment Thread
Scenario: A recursive comment component where each comment can have replies. Each level needs different data fields.
// CommentThread.jsx
function CommentThread({post}) {
const data = useFragment(
graphql`
fragment CommentThread_post on Post {
id
commentCount # Parent needs total count
topLevelComments(first: 20) {
edges {
node {
id
...Comment_comment
}
}
}
}
`,
post
);
return (
<div>
<h3>{data.commentCount} Comments</h3>
{data.topLevelComments.edges.map(({node}) => (
<Comment key={node.id} comment={node} depth={0} />
))}
</div>
);
}
// Comment.jsx (recursive)
function Comment({comment, depth}) {
const data = useFragment(
graphql`
fragment Comment_comment on Comment {
id
author { name, avatar }
body
createdAt
replies(first: 5) {
edges {
node {
id
...Comment_comment # Recursive fragment!
}
}
}
}
`,
comment
);
const [showReplies, setShowReplies] = useState(true);
return (
<div style={{marginLeft: depth * 20}}>
<div className="comment-header">
<img src={data.author.avatar} alt="" />
<strong>{data.author.name}</strong>
<time>{data.createdAt}</time>
</div>
<p>{data.body}</p>
{data.replies.edges.length > 0 && (
<button onClick={() => setShowReplies(!showReplies)}>
{showReplies ? 'Hide' : 'Show'} {data.replies.edges.length} replies
</button>
)}
{showReplies && data.replies.edges.map(({node}) => (
<Comment
key={node.id}
comment={node}
depth={depth + 1}
/>
))}
</div>
);
}
Boundary behavior in recursion:
βββββββββββββββββββββββββββββββββββββββ β CommentThread (depth 0) β β Knows: commentCount β β βββββββββββββββββββββββββββββββββββ β β β Comment A (depth 0) β β β β Knows: author, body, createdAt β β β β βββββββββββββββββββββββββββββββ β β β β β Comment A.1 (depth 1) β β β β β β Knows: author, body, time β β β β β β βββββββββββββββββββββββββββ β β β β β β β Comment A.1.a (depth 2)β β β β β β β β Same fragment, new ref β β β β β β β βββββββββββββββββββββββββββ β β β β β βββββββββββββββββββββββββββββββ β β β βββββββββββββββββββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββ Each instance has its own masked data even though they use the same fragment
Key insight: Even though Comment_comment is used recursively, each instance maintains its own data boundary. Comment A cannot access Comment A.1's author dataβeach must call useFragment independently.
Example 3: Conditional Data Boundaries
Scenario: An admin dashboard that shows different data based on user permissions. Boundaries prevent accidental data leaks.
// UserProfile.jsx
function UserProfile({user}) {
const data = useFragment(
graphql`
fragment UserProfile_user on User {
id
name
role
...PublicProfile_user
...AdminProfile_user @include(if: $isAdmin)
}
`,
user
);
return (
<div>
<h1>{data.name}</h1>
<PublicProfile user={data} />
{data.role === 'ADMIN' && <AdminProfile user={data} />}
</div>
);
}
// PublicProfile.jsx - safe data all users can see
function PublicProfile({user}) {
const data = useFragment(
graphql`
fragment PublicProfile_user on User {
bio
avatar
publicPosts(first: 10) {
edges { node { id, title } }
}
}
`,
user
);
return (
<section>
<img src={data.avatar} alt="" />
<p>{data.bio}</p>
<h3>Recent Posts</h3>
{/* Render posts... */}
</section>
);
}
// AdminProfile.jsx - sensitive data only admins see
function AdminProfile({user}) {
const data = useFragment(
graphql`
fragment AdminProfile_user on User {
email # Sensitive!
ipAddress # Very sensitive!
loginHistory(first: 20) {
edges {
node { timestamp, location }
}
}
accountFlags { suspended, verified, flagged }
}
`,
user
);
return (
<section className="admin-only">
<h3>β οΈ Admin Data</h3>
<p>Email: {data.email}</p>
<p>Last IP: {data.ipAddress}</p>
{/* More sensitive data... */}
</section>
);
}
Security benefit: Even if someone accidentally renders <AdminProfile user={data} /> without checking permissions, they'd pass an opaque reference that doesn't contain admin data unless the @include(if: $isAdmin) directive was true at query time. The boundary acts as a second line of defense.
Example 4: Optimistic Updates with Boundaries
Scenario: A like button that updates immediately while maintaining data boundaries.
// PostCard.jsx
function PostCard({post}) {
const data = useFragment(
graphql`
fragment PostCard_post on Post {
id
title
excerpt
...LikeButton_post
...ShareButton_post
}
`,
post
);
return (
<article>
<h2>{data.title}</h2>
<p>{data.excerpt}</p>
<footer>
<LikeButton post={data} />
<ShareButton post={data} />
</footer>
</article>
);
}
// LikeButton.jsx
function LikeButton({post}) {
const data = useFragment(
graphql`
fragment LikeButton_post on Post {
id
likeCount
viewerHasLiked
}
`,
post
);
const [commitLike] = useMutation(graphql`
mutation LikeButtonMutation($postId: ID!) {
likePost(postId: $postId) {
post {
id
likeCount
viewerHasLiked
}
}
}
`);
const handleLike = () => {
commitLike({
variables: {postId: data.id},
optimisticResponse: {
likePost: {
post: {
id: data.id,
likeCount: data.likeCount + (data.viewerHasLiked ? -1 : 1),
viewerHasLiked: !data.viewerHasLiked,
},
},
},
});
};
return (
<button onClick={handleLike}>
{data.viewerHasLiked ? 'β€οΈ' : 'π€'} {data.likeCount}
</button>
);
}
Why boundaries help here:
- PostCard doesn't re-render when like count changes (it doesn't declare
likeCount) - Only LikeButton re-renders, minimizing UI updates
- ShareButton is completely unaffected
- Optimistic update is scoped exactly to the data LikeButton declared
Render Optimization via Boundaries:
β Without boundaries (all data in parent):
Like changes β PostCard re-renders
β All children re-render
β Expensive!
β
With boundaries:
Like changes β Only LikeButton re-renders
β Parent & siblings unaffected
β Efficient!
Common Mistakes
β Mistake 1: Trying to Access Masked Data
Problem:
function Parent({user}) {
const data = useFragment(
graphql`
fragment Parent_user on User {
id
...Child_user
}
`,
user
);
// β This doesn't work!
console.log(data.Child_user.email); // undefined
console.log(data.email); // undefined
}
Why it fails: Fragment spreads create opaque references, not nested objects. The data is masked.
Solution: If Parent needs the data, declare it explicitly:
fragment Parent_user on User {
id
email // β
Explicitly declare if Parent needs it
...Child_user
}
β Mistake 2: Passing Raw Data Instead of References
Problem:
function Parent({user}) {
const data = useFragment(parentFragment, user);
const childData = useFragment(childFragment, data); // β WRONG!
return <Child data={childData} />;
}
Why it fails: You're unwrapping the child's data in the parent, violating the boundary.
Solution:
function Parent({user}) {
const data = useFragment(parentFragment, user);
return <Child user={data} />; // β
Pass reference, let Child unwrap
}
function Child({user}) {
const data = useFragment(childFragment, user); // Child unwraps it
return <div>{data.email}</div>;
}
β Mistake 3: Over-Fetching to "Help" Children
Problem:
// Parent trying to be "helpful"
fragment Parent_user on User {
id
name
email // Parent doesn't use this
phone // or this
address // or this - but fetching "just in case"
...Child_user
}
Why it's bad:
- Wastes network bandwidth
- Increases query complexity
- Couples parent to child's needs
- If Child changes its requirements, Parent has obsolete fields
Solution:
// Parent fetches only what it uses
fragment Parent_user on User {
id
name // Parent renders this
...Child_user // Child declares its own needs
}
β Mistake 4: Not Using Fragments at All
Problem:
// Query fetches everything at the top
query AppQuery {
viewer {
id
name
email
phone
avatar
bio
posts { id, title, body, likes }
comments { id, text, createdAt }
# ... 50 more fields
}
}
// Then prop-drilling everywhere
<Header name={data.viewer.name} avatar={data.viewer.avatar} />
<Profile bio={data.viewer.bio} email={data.viewer.email} />
Why it's bad:
- No boundaries = no data masking benefits
- Tight coupling between all components
- Impossible to optimize re-renders
- Hard to test components in isolation
Solution:
query AppQuery {
viewer {
id # Only what App itself needs
...Header_user
...Profile_user
}
}
// Each component declares and unwraps its own data
β οΈ Mistake 5: Confusing Fragments with Props
Problem: Thinking of fragment spreads like prop spreading in React.
// This mental model is WRONG:
fragment Parent_user on User {
...Child_user // β NOT like {...childProps}
}
// Assuming Parent can now access Child's fields
Correct mental model: Fragment spreads are data access grants, not data copies.
// Think of it like:
fragment Parent_user on User {
...Child_user // "I need data FOR Child, but can't see it myself"
}
The parent is requesting data on behalf of the child, but Relay enforces that only the child can access it.
π§ Debugging Boundary Issues
Symptom: Data is undefined in a component.
Diagnostic steps:
- Check fragment declaration - Did you declare the field?
// Is the field in YOUR fragment?
fragment MyComponent_user on User {
email // β Is this line present?
}
- Check fragment usage - Are you calling
useFragment?
const data = useFragment(MyComponent_user, userRef);
console.log(data.email); // Now it should work
- Check parent spread - Did parent include your fragment?
// In parent:
fragment Parent_user on User {
id
...MyComponent_user // β Is this line present?
}
- Check reference passing - Are you passing the right prop?
// Parent:
return <MyComponent user={data} />; // Passing 'user'
// Child:
function MyComponent({user}) { // Receiving 'user' - names must match!
const data = useFragment(fragment, user);
}
Key Takeaways
π― Core Principles:
- Data masking is mandatory - Parents cannot access child fragment data, even if they wanted to
- Fragments create boundaries - Each fragment declares an independent, isolated data requirement
- Opaque references enforce boundaries - Fragment spreads produce references that only
useFragmentcan unwrap - Duplication is OK - Multiple fragments can request the same field; Relay deduplicates automatically
- Boundaries enable optimization - Relay can minimize re-renders because it knows exactly which components depend on which data
π‘ Practical Guidelines:
- β DO declare only the fields your component directly uses
- β DO pass fragment references to children, not raw data
- β
DO let each component call
useFragmentfor its own data - β DO duplicate field declarations when multiple components need them
- β
DO use
@argumentswhen parents need to parameterize child queries - β DON'T try to access data from spread fragments
- β DON'T over-fetch fields you don't use
- β DON'T unwrap child fragments in parent components
- β DON'T prop-drill fragment data
π§ Mental Models:
Think of fragments as:
- π Privacy walls between apartments (each component has its own space)
- π¦ Sealed packages (you can pass them around but can't peek inside without the key)
- π« Access tickets (spreading a fragment is requesting data access, not getting the data)
- ποΈ File permissions (read access must be explicitly granted per file/field)
Data flow pattern:
Query β Parent.useFragment() β Parent accesses own fields
β Pass reference to Child
β Child.useFragment() β Child accesses own fields
π Quick Reference Card
| Concept | Description | Code Pattern |
|---|---|---|
| Data Masking | Automatic hiding of fragment data from other components | ...Child_user β opaque ref |
| Opaque Reference | Unreadable object produced by fragment spread | data.Child_user β can't access fields |
| Fragment Unwrapping | Using useFragment to access masked data |
useFragment(frag, ref) β real data |
| Boundary | Invisible barrier preventing cross-component data access | Parent can't see Child's fields |
| Deduplication | Relay automatically merges duplicate field requests | Multiple id declarations β one fetch |
| @arguments | Parameterize child fragments from parent | ...Child @arguments(n: 10) |
Boundary Enforcement Checklist:
- β Each component has its own fragment
- β
Each component calls
useFragmentfor its data - β Pass references, not raw data
- β Declare exactly what you render
- β Trust Relay to optimize
π Further Study
- Relay Documentation: Fragments - Official guide to fragment composition and data masking
- Thinking in Relay - Core architectural concepts including data boundaries
- Relay Best Practices - Recommended patterns for fragment organization and component boundaries
Next Steps: Now that you understand parent-child data boundaries, you're ready to explore Data Masking Architecture - the broader system that makes these boundaries possible and how Relay uses them to optimize your entire application.