You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

Advanced Patterns

Master Suspense integration, error boundaries, and performance optimization strategies

Advanced Relay Patterns

Master advanced Relay patterns with free flashcards and spaced repetition practice. This lesson covers pagination strategies, optimistic updates, and connection managementβ€”essential techniques for building performant GraphQL applications with Relay Modern. Whether you're scaling a real-time app or optimizing data fetching, these patterns will elevate your React development skills.

Welcome to Advanced Relay πŸ’»

Relay is Facebook's powerful GraphQL client designed specifically for React applications. While basic Relay usage gets you started with declarative data fetching, advanced patterns unlock the full potential of this framework. These patterns address real-world challenges: handling large datasets efficiently, updating the UI instantly before server responses, managing complex cache interactions, and structuring queries for optimal performance.

In this lesson, we'll dive deep into battle-tested patterns used by production applications at scale. You'll learn when and how to apply each pattern, understand their trade-offs, and avoid common pitfalls that can lead to stale data or degraded user experience.

Core Concepts in Depth πŸ”

1. Connection Pattern and Pagination πŸ“„

The connection pattern is Relay's standardized way of handling lists and pagination. It provides a consistent interface for paginated data across your entire application.

Connection Structure: A connection consists of:

  • edges: An array of edge objects, each containing a node (the actual item) and a cursor (position marker)
  • pageInfo: Metadata with hasNextPage, hasPreviousPage, startCursor, and endCursor
  • totalCount: Optional total number of items
FieldTypePurpose
edgesArrayContainer for nodes with cursors
nodeObjectThe actual data item
cursorStringOpaque position identifier
pageInfoObjectPagination metadata

Pagination Strategies:

Forward Pagination ("Load More"):

const {data, loadNext, hasNext, isLoadingNext} = usePaginationFragment(
  graphql`
    fragment PostList_user on User
    @refetchable(queryName: "PostListPaginationQuery") {
      posts(first: $count, after: $cursor)
      @connection(key: "PostList_user_posts") {
        edges {
          node {
            id
            title
            content
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  `,
  userRef
);

// Load 10 more items
const handleLoadMore = () => {
  if (hasNext && !isLoadingNext) {
    loadNext(10);
  }
};

Backward Pagination ("Load Previous"): Useful for chat applications or infinite scroll up:

const {data, loadPrevious, hasPrevious} = usePaginationFragment(
  graphql`
    fragment Messages_conversation on Conversation
    @refetchable(queryName: "MessagesPaginationQuery") {
      messages(last: $count, before: $cursor)
      @connection(key: "Messages_conversation_messages") {
        edges {
          node {
            id
            text
            timestamp
          }
        }
      }
    }
  `,
  conversationRef
);

Bi-directional Pagination: Combine both approaches when users need to navigate in both directions (timeline views, document editors).

πŸ’‘ Tip: Always use the @connection directive with a unique key to ensure Relay properly merges paginated results in the store.

2. Optimistic Updates πŸš€

Optimistic updates provide instant UI feedback by immediately updating the cache before the server responds. This creates a snappy, responsive user experience, especially on slow networks.

When to Use Optimistic Updates:

  • βœ… Simple, predictable mutations (like/unlike, follow/unfollow)
  • βœ… Actions where the outcome is nearly certain
  • βœ… UI interactions that users expect to be instant
  • ❌ Complex calculations the client can't replicate
  • ❌ Operations with high failure rates
  • ❌ Security-sensitive actions requiring server validation

Implementation Patterns:

Basic Optimistic Response:

const [commit, isInFlight] = useMutation(graphql`
  mutation LikePostMutation($input: LikePostInput!) {
    likePost(input: $input) {
      post {
        id
        likeCount
        viewerHasLiked
      }
    }
  }
`);

const handleLike = (postId, currentCount) => {
  commit({
    variables: {
      input: {postId}
    },
    optimisticResponse: {
      likePost: {
        post: {
          id: postId,
          likeCount: currentCount + 1,
          viewerHasLiked: true
        }
      }
    }
  });
};

Optimistic Updater (Complex Changes): When you need to modify multiple records or connections:

commit({
  variables: {input: {userId, listId}},
  optimisticUpdater: (store) => {
    const user = store.get(userId);
    const list = store.get(listId);
    
    // Add user to list's connection
    const connection = ConnectionHandler.getConnection(
      list,
      'UserList_members'
    );
    
    const edge = ConnectionHandler.createEdge(
      store,
      connection,
      user,
      'UserEdge'
    );
    
    ConnectionHandler.insertEdgeBefore(connection, edge);
    
    // Update count optimistically
    list.setValue(
      (list.getValue('memberCount') || 0) + 1,
      'memberCount'
    );
  }
});

Rollback on Error: Relay automatically reverts optimistic updates when mutations fail, but you can enhance error handling:

commit({
  variables: {input},
  optimisticResponse: {...},
  onError: (error) => {
    // Show user-friendly error message
    showToast('Failed to update. Please try again.');
    // Log for debugging
    console.error('Mutation failed:', error);
  }
});

πŸ’‘ Pro Tip: For critical data consistency, use optimistic updates with a loading indicator. Show the optimistic state but also indicate the request is in-flight until server confirmation arrives.

3. Store Updaters and Cache Management πŸ—„οΈ

Store updaters give you direct control over Relay's normalized cache. This is powerful for scenarios where declarative fragments aren't sufficient.

The Relay Store: Relay maintains a normalized cache where each object is stored once by its unique ID. References between objects use these IDs, preventing data duplication and ensuring consistency.

Common Updater Patterns:

Adding Items to Connections:

const updater = (store) => {
  const payload = store.getRootField('createPost');
  const newPost = payload.getLinkedRecord('post');
  const user = store.get(userId);
  
  const connection = ConnectionHandler.getConnection(
    user,
    'PostList_user_posts'
  );
  
  if (connection) {
    const edge = ConnectionHandler.createEdge(
      store,
      connection,
      newPost,
      'PostEdge'
    );
    ConnectionHandler.insertEdgeAfter(connection, edge);
  }
};

Removing Items:

const updater = (store) => {
  const deletedPostId = store.getRootField('deletePost')
    .getValue('deletedPostId');
  
  ConnectionHandler.deleteNode(
    connection,
    deletedPostId
  );
  
  // Also remove from store entirely
  store.delete(deletedPostId);
};

Updating Scalar Fields:

const updater = (store) => {
  const user = store.get(userId);
  user.setValue(
    user.getValue('followerCount') + 1,
    'followerCount'
  );
};

Creating Records Manually:

const updater = (store) => {
  const newRecord = store.create(
    `client:new-notification:${Date.now()}`,
    'Notification'
  );
  newRecord.setValue('New message received!', 'text');
  newRecord.setValue(Date.now(), 'timestamp');
  
  // Link to user's notifications
  const user = store.get(userId);
  const notifications = user.getLinkedRecords('notifications') || [];
  user.setLinkedRecords([newRecord, ...notifications], 'notifications');
};

Connection Filters: When working with filtered connections, specify the filter key:

const connection = ConnectionHandler.getConnection(
  user,
  'TaskList_user_tasks',
  {status: 'ACTIVE'} // Filter arguments
);

4. Refetching and Polling Strategies πŸ”„

Refetching loads fresh data from the server, while polling does this automatically at intervals. Both ensure data freshness but have different use cases.

Refetch Patterns:

Query Refetching:

const [queryRef, loadQuery] = useQueryLoader(MyQuery);

// Refetch with same variables
const handleRefresh = () => {
  loadQuery({id: userId}, {fetchPolicy: 'network-only'});
};

// Refetch with new variables
const handleSearch = (term) => {
  loadQuery({searchTerm: term});
};

Fragment Refetching:

const {data, refetch} = useRefetchableFragment(
  graphql`
    fragment UserProfile_user on User
    @refetchable(queryName: "UserProfileRefetchQuery") {
      name
      email
      lastActive
    }
  `,
  userRef
);

// Refetch this fragment's data
const handleUpdate = () => {
  refetch({}, {fetchPolicy: 'network-only'});
};

Polling for Real-time Updates:

const {
  data,
  refetch,
  isRefetching
} = useRefetchableFragment(...);

// Poll every 30 seconds
useEffect(() => {
  const interval = setInterval(() => {
    refetch({}, {fetchPolicy: 'network-only'});
  }, 30000);
  
  return () => clearInterval(interval);
}, [refetch]);

Smart Polling with Visibility API: Stop polling when tab is hidden to save resources:

useEffect(() => {
  let interval;
  
  const startPolling = () => {
    interval = setInterval(() => {
      refetch({}, {fetchPolicy: 'network-only'});
    }, 30000);
  };
  
  const stopPolling = () => {
    if (interval) clearInterval(interval);
  };
  
  const handleVisibilityChange = () => {
    if (document.hidden) {
      stopPolling();
    } else {
      startPolling();
      refetch({}, {fetchPolicy: 'network-only'}); // Immediate refresh
    }
  };
  
  document.addEventListener('visibilitychange', handleVisibilityChange);
  startPolling();
  
  return () => {
    stopPolling();
    document.removeEventListener('visibilitychange', handleVisibilityChange);
  };
}, [refetch]);

Fetch Policies:

PolicyBehaviorUse Case
store-or-networkUse cache if available, else fetchDefault, most cases
store-onlyNever fetch, cache onlyOffline mode
network-onlyAlways fetch, ignore cacheForce refresh
store-and-networkReturn cache, then fetchShow stale data fast

5. Fragment Composition and Colocation 🧩

Fragment colocation means defining data requirements alongside the components that use them. This is a core Relay principle that improves maintainability and prevents over-fetching.

Composing Fragments:

Parent Component:

function FeedScreen() {
  const data = useLazyLoadQuery(
    graphql`
      query FeedScreenQuery {
        viewer {
          ...FeedList_viewer
        }
      }
    `,
    {}
  );
  
  return <FeedList viewer={data.viewer} />;
}

Child Component:

function FeedList({viewer}) {
  const data = useFragment(
    graphql`
      fragment FeedList_viewer on User {
        posts(first: 10) @connection(key: "FeedList_viewer_posts") {
          edges {
            node {
              id
              ...PostCard_post
            }
          }
        }
      }
    `,
    viewer
  );
  
  return data.posts.edges.map(({node}) => (
    <PostCard key={node.id} post={node} />
  ));
}

Leaf Component:

function PostCard({post}) {
  const data = useFragment(
    graphql`
      fragment PostCard_post on Post {
        title
        content
        author {
          name
          avatar
        }
        createdAt
      }
    `,
    post
  );
  
  return (
    <div>
      <h3>{data.title}</h3>
      <p>{data.content}</p>
      <Author name={data.author.name} avatar={data.author.avatar} />
    </div>
  );
}

Benefits of Fragment Composition:

  • 🎯 Each component owns its data requirements
  • πŸ”§ Easy to modify without breaking other components
  • πŸ“¦ Automatic dead code elimination (unused fragments are removed)
  • πŸš€ Single query fetches all nested data efficiently
  • πŸ§ͺ Components remain testable with mock data

Fragment Arguments: Pass variables down through fragments:

graphql`
  fragment PostList_user on User
  @argumentDefinitions(
    count: {type: "Int", defaultValue: 10}
    includeImages: {type: "Boolean!", defaultValue: false}
  ) {
    posts(first: $count) {
      edges {
        node {
          id
          title
          images @include(if: $includeImages) {
            url
          }
        }
      }
    }
  }
`

Practical Examples πŸ› οΈ

Example 1: Infinite Scroll Feed with Optimistic Likes

This example combines pagination, optimistic updates, and fragment composition:

import {graphql, usePaginationFragment, useMutation} from 'react-relay';
import {useCallback} from 'react';

function InfiniteFeed({queryRef}) {
  const {
    data,
    loadNext,
    hasNext,
    isLoadingNext
  } = usePaginationFragment(
    graphql`
      fragment InfiniteFeed_query on Query
      @refetchable(queryName: "InfiniteFeedPaginationQuery") {
        posts(first: $count, after: $cursor)
        @connection(key: "InfiniteFeed_posts") {
          edges {
            node {
              id
              title
              content
              likeCount
              viewerHasLiked
            }
          }
        }
      }
    `,
    queryRef
  );
  
  const [commitLike] = useMutation(graphql`
    mutation InfiniteFeedLikeMutation($input: LikePostInput!) {
      likePost(input: $input) {
        post {
          id
          likeCount
          viewerHasLiked
        }
      }
    }
  `);
  
  const handleLike = useCallback((postId, currentCount, isLiked) => {
    commitLike({
      variables: {
        input: {postId}
      },
      optimisticResponse: {
        likePost: {
          post: {
            id: postId,
            likeCount: isLiked ? currentCount - 1 : currentCount + 1,
            viewerHasLiked: !isLiked
          }
        }
      }
    });
  }, [commitLike]);
  
  const handleScroll = useCallback((e) => {
    const bottom = e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight;
    if (bottom && hasNext && !isLoadingNext) {
      loadNext(10);
    }
  }, [loadNext, hasNext, isLoadingNext]);
  
  return (
    <div onScroll={handleScroll} style={{overflowY: 'auto', height: '100vh'}}>
      {data.posts.edges.map(({node}) => (
        <div key={node.id} style={{padding: '20px', borderBottom: '1px solid #eee'}}>
          <h2>{node.title}</h2>
          <p>{node.content}</p>
          <button
            onClick={() => handleLike(node.id, node.likeCount, node.viewerHasLiked)}
            style={{
              background: node.viewerHasLiked ? '#e74c3c' : '#ecf0f1',
              color: node.viewerHasLiked ? 'white' : 'black'
            }}
          >
            ❀️ {node.likeCount}
          </button>
        </div>
      ))}
      {isLoadingNext && <div style={{padding: '20px', textAlign: 'center'}}>Loading more...</div>}
    </div>
  );
}

Key Points:

  • βœ… Uses @connection directive for proper pagination merging
  • βœ… Optimistic response provides instant UI feedback
  • βœ… Scroll detection triggers automatic loading
  • βœ… Like/unlike works seamlessly without server delay

Example 2: Real-time Notification System with Store Updates

Implementing a notification badge that updates via WebSocket:

import {graphql, useFragment, useMutation, useSubscription} from 'react-relay';
import {useEffect} from 'react';

function NotificationBadge({userRef}) {
  const data = useFragment(
    graphql`
      fragment NotificationBadge_user on User {
        id
        unreadNotifications {
          totalCount
        }
      }
    `,
    userRef
  );
  
  // Subscribe to new notifications
  useSubscription({
    subscription: graphql`
      subscription NotificationBadgeSubscription($userId: ID!) {
        notificationReceived(userId: $userId) {
          notification {
            id
            text
            createdAt
            read
          }
        }
      }
    `,
    variables: {userId: data.id},
    updater: (store) => {
      const payload = store.getRootField('notificationReceived');
      const newNotification = payload.getLinkedRecord('notification');
      
      const user = store.get(data.id);
      const unreadConnection = user.getLinkedRecord('unreadNotifications');
      
      // Increment count
      const currentCount = unreadConnection.getValue('totalCount') || 0;
      unreadConnection.setValue(currentCount + 1, 'totalCount');
      
      // Add to notifications list
      const notifications = user.getLinkedRecords('notifications') || [];
      user.setLinkedRecords([newNotification, ...notifications], 'notifications');
    }
  });
  
  const [markAllRead] = useMutation(graphql`
    mutation NotificationBadgeMarkAllReadMutation($userId: ID!) {
      markAllNotificationsRead(userId: $userId) {
        user {
          id
          unreadNotifications {
            totalCount
          }
        }
      }
    }
  `);
  
  const handleMarkAllRead = () => {
    markAllRead({
      variables: {userId: data.id},
      optimisticUpdater: (store) => {
        const user = store.get(data.id);
        const unreadConnection = user.getLinkedRecord('unreadNotifications');
        unreadConnection.setValue(0, 'totalCount');
      }
    });
  };
  
  const count = data.unreadNotifications.totalCount;
  
  return (
    <div style={{position: 'relative', cursor: 'pointer'}} onClick={handleMarkAllRead}>
      πŸ””
      {count > 0 && (
        <span style={{
          position: 'absolute',
          top: '-8px',
          right: '-8px',
          background: '#e74c3c',
          color: 'white',
          borderRadius: '10px',
          padding: '2px 6px',
          fontSize: '12px'
        }}>
          {count > 99 ? '99+' : count}
        </span>
      )}
    </div>
  );
}

Key Points:

  • βœ… Subscription automatically updates cache via custom updater
  • βœ… Manual store manipulation increments counter
  • βœ… Optimistic update provides instant feedback on mark-as-read
  • βœ… Badge updates in real-time without polling

Example 3: Complex Search with Debounced Refetching

Searching with automatic refetching and proper loading states:

import {graphql, useLazyLoadQuery, useQueryLoader} from 'react-relay';
import {useState, useEffect, useCallback, useTransition} from 'react';

const SearchQuery = graphql`
  query SearchQuery($searchTerm: String!, $filters: SearchFilters) {
    search(term: $searchTerm, filters: $filters) {
      results {
        id
        title
        description
        relevanceScore
      }
      totalCount
      facets {
        category
        count
      }
    }
  }
`;

function SearchScreen() {
  const [queryRef, loadQuery] = useQueryLoader(SearchQuery);
  const [searchTerm, setSearchTerm] = useState('');
  const [filters, setFilters] = useState({});
  const [isPending, startTransition] = useTransition();
  
  // Debounce search input
  useEffect(() => {
    if (searchTerm.length < 2) return;
    
    const timer = setTimeout(() => {
      startTransition(() => {
        loadQuery(
          {searchTerm, filters},
          {fetchPolicy: 'network-only'}
        );
      });
    }, 300); // 300ms debounce
    
    return () => clearTimeout(timer);
  }, [searchTerm, filters, loadQuery]);
  
  const handleSearchChange = useCallback((e) => {
    setSearchTerm(e.target.value);
  }, []);
  
  const handleFilterChange = useCallback((filterKey, value) => {
    setFilters(prev => ({
      ...prev,
      [filterKey]: value
    }));
  }, []);
  
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={handleSearchChange}
        placeholder="Search..."
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          border: '2px solid #3498db',
          borderRadius: '8px'
        }}
      />
      
      {isPending && (
        <div style={{padding: '10px', color: '#7f8c8d'}}>Searching...</div>
      )}
      
      {queryRef && (
        <SearchResults queryRef={queryRef} onFilterChange={handleFilterChange} />
      )}
    </div>
  );
}

function SearchResults({queryRef, onFilterChange}) {
  const data = useLazyLoadQuery(SearchQuery, queryRef);
  
  return (
    <div style={{marginTop: '20px'}}>
      <div style={{marginBottom: '20px'}}>
        <strong>Found {data.search.totalCount} results</strong>
        <div style={{display: 'flex', gap: '10px', marginTop: '10px'}}>
          {data.search.facets.map(facet => (
            <button
              key={facet.category}
              onClick={() => onFilterChange('category', facet.category)}
              style={{
                padding: '6px 12px',
                background: '#ecf0f1',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}
            >
              {facet.category} ({facet.count})
            </button>
          ))}
        </div>
      </div>
      
      {data.search.results.map(result => (
        <div key={result.id} style={{
          padding: '15px',
          marginBottom: '10px',
          background: '#f8f9fa',
          borderRadius: '8px'
        }}>
          <h3>{result.title}</h3>
          <p>{result.description}</p>
          <small style={{color: '#7f8c8d'}}>Score: {result.relevanceScore}</small>
        </div>
      ))}
    </div>
  );
}

Key Points:

  • βœ… Debouncing prevents excessive API calls during typing
  • βœ… useTransition provides non-blocking loading states
  • βœ… Filters trigger automatic refetch with new variables
  • βœ… Facets enable dynamic filtering based on results

Example 4: Batch Mutation with Rollback Strategy

Handling multiple mutations with proper error recovery:

import {graphql, useMutation} from 'react-relay';
import {useState} from 'react';

function TodoBatchUpdate({todos}) {
  const [errors, setErrors] = useState([]);
  
  const [commitUpdate] = useMutation(graphql`
    mutation TodoBatchUpdateMutation($input: UpdateTodoInput!) {
      updateTodo(input: $input) {
        todo {
          id
          completed
          completedAt
        }
      }
    }
  `);
  
  const handleMarkAllComplete = async () => {
    setErrors([]);
    const failedIds = [];
    
    // Execute mutations in parallel with Promise.all
    const mutations = todos
      .filter(todo => !todo.completed)
      .map(todo => {
        return new Promise((resolve) => {
          commitUpdate({
            variables: {
              input: {
                id: todo.id,
                completed: true
              }
            },
            optimisticResponse: {
              updateTodo: {
                todo: {
                  id: todo.id,
                  completed: true,
                  completedAt: new Date().toISOString()
                }
              }
            },
            onCompleted: () => resolve({success: true, id: todo.id}),
            onError: (error) => {
              failedIds.push(todo.id);
              resolve({success: false, id: todo.id, error});
            }
          });
        });
      });
    
    const results = await Promise.all(mutations);
    
    // Handle failures
    const failed = results.filter(r => !r.success);
    if (failed.length > 0) {
      setErrors(failed.map(f => `Failed to update todo ${f.id}`));
      
      // Optional: Revert failed items
      failed.forEach(f => {
        commitUpdate({
          variables: {
            input: {
              id: f.id,
              completed: false
            }
          },
          updater: (store) => {
            const todo = store.get(f.id);
            if (todo) {
              todo.setValue(false, 'completed');
              todo.setValue(null, 'completedAt');
            }
          }
        });
      });
    }
  };
  
  return (
    <div>
      <button onClick={handleMarkAllComplete} style={{
        padding: '10px 20px',
        background: '#2ecc71',
        color: 'white',
        border: 'none',
        borderRadius: '6px',
        cursor: 'pointer'
      }}>
        βœ“ Mark All Complete
      </button>
      
      {errors.length > 0 && (
        <div style={{
          marginTop: '10px',
          padding: '10px',
          background: '#fee',
          border: '1px solid #e74c3c',
          borderRadius: '4px',
          color: '#c0392b'
        }}>
          <strong>Errors occurred:</strong>
          <ul>
            {errors.map((err, i) => <li key={i}>{err}</li>)}
          </ul>
        </div>
      )}
    </div>
  );
}

Key Points:

  • βœ… Parallel execution improves performance
  • βœ… Individual error handling prevents cascade failures
  • βœ… Failed mutations can be rolled back manually
  • βœ… User receives clear feedback on partial failures

Common Mistakes and How to Avoid Them ⚠️

1. Forgetting @connection Directive

❌ Wrong:

fragment UserPosts_user on User {
  posts(first: $count, after: $cursor) {
    edges {
      node { id title }
    }
  }
}

βœ… Correct:

fragment UserPosts_user on User {
  posts(first: $count, after: $cursor)
  @connection(key: "UserPosts_user_posts") {
    edges {
      node { id title }
    }
  }
}

Why: Without @connection, Relay creates separate cache entries for each page, causing pagination to fail. The connection directive ensures proper merging.

2. Overly Complex Optimistic Responses

❌ Wrong:

optimisticResponse: {
  createPost: {
    post: {
      id: 'temp-id',
      title: input.title,
      content: input.content,
      author: {
        id: userId,
        name: currentUser.name,
        followers: currentUser.followers + 1, // Complex calculation
        posts: [...currentUser.posts, {/*...*/}] // Deep nesting
      },
      comments: [],
      likes: calculateInitialLikes() // Function call
    }
  }
}

βœ… Correct:

optimisticResponse: {
  createPost: {
    post: {
      id: 'temp-id',
      title: input.title,
      content: input.content,
      author: {
        id: userId
      },
      commentCount: 0,
      likeCount: 0
    }
  }
}

Why: Optimistic responses should only include fields that are immediately knowable. Let the server response populate complex or calculated fields. Keep it simple!

3. Missing Error Handling in Mutations

❌ Wrong:

commit({
  variables: {input},
  optimisticResponse: {...}
});

βœ… Correct:

commit({
  variables: {input},
  optimisticResponse: {...},
  onCompleted: (response, errors) => {
    if (errors) {
      console.error('Mutation errors:', errors);
      showToast('Something went wrong');
    } else {
      showToast('Success!');
    }
  },
  onError: (error) => {
    console.error('Network error:', error);
    showToast('Network error. Please try again.');
  }
});

Why: Networks are unreliable. Always handle both GraphQL errors (in onCompleted) and network errors (in onError).

4. Polling Without Cleanup

❌ Wrong:

useEffect(() => {
  setInterval(() => {
    refetch({});
  }, 5000);
}, [refetch]);

βœ… Correct:

useEffect(() => {
  const interval = setInterval(() => {
    refetch({});
  }, 5000);
  
  return () => clearInterval(interval);
}, [refetch]);

Why: Without cleanup, intervals persist after component unmount, causing memory leaks and unnecessary network requests.

5. Incorrect Store Updater Patterns

❌ Wrong:

updater: (store) => {
  const payload = store.getRootField('createComment');
  const comment = payload.getLinkedRecord('comment');
  
  // Trying to mutate the record directly
  comment.content = 'Modified'; // Won't work!
  
  // Missing null checks
  const post = store.get(postId);
  const comments = post.getLinkedRecords('comments');
  post.setLinkedRecords([...comments, comment], 'comments');
}

βœ… Correct:

updater: (store) => {
  const payload = store.getRootField('createComment');
  const comment = payload.getLinkedRecord('comment');
  
  if (!comment) return; // Early return if missing
  
  const post = store.get(postId);
  if (!post) return;
  
  const comments = post.getLinkedRecords('comments') || [];
  post.setLinkedRecords([...comments, comment], 'comments');
  
  // Update count
  post.setValue(comments.length + 1, 'commentCount');
}

Why: Store records are immutable. Use setValue/setLinkedRecords methods. Always add null checksβ€”missing data is common during optimistic updates.

6. Using Wrong Pagination Direction

❌ Wrong (for chat messages):

// Using first/after for chat (newest messages appear at bottom)
messages(first: $count, after: $cursor) @connection(...) {
  edges { node { text } }
}

βœ… Correct:

// Using last/before for chat (load older messages upward)
messages(last: $count, before: $cursor) @connection(...) {
  edges { node { text } }
}

Why: Chat UIs typically show newest messages at bottom and load older ones upward. Use last/before for this pattern. Use first/after for traditional "load more" at the bottom.

7. Not Using Fragment Arguments for Variations

❌ Wrong:

// Creating separate fragments for slight variations
fragment PostCardFull_post on Post { title content images author }
fragment PostCardCompact_post on Post { title author }

βœ… Correct:

fragment PostCard_post on Post
@argumentDefinitions(
  includeContent: {type: "Boolean!", defaultValue: true}
  includeImages: {type: "Boolean!", defaultValue: true}
) {
  title
  content @include(if: $includeContent)
  images @include(if: $includeImages) { url }
  author { name }
}

Why: Fragment arguments reduce duplication and make components more flexible. Use @include and @skip directives to conditionally fetch fields.

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Pattern When to Use Key Hook/API
Pagination Large lists, infinite scroll usePaginationFragment + @connection
Optimistic Updates Instant UI feedback optimisticResponse in mutations
Store Updaters Complex cache modifications updater + ConnectionHandler
Refetching Refresh stale data useRefetchableFragment + @refetchable
Polling Real-time updates without WebSocket setInterval + refetch + cleanup
Fragment Composition Component data requirements useFragment + spread syntax

Essential Directives:

  • @connection(key: "UniqueKey") - Merge paginated results
  • @refetchable(queryName: "QueryName") - Enable refetching
  • @argumentDefinitions() - Pass variables to fragments
  • @include(if: $var) - Conditionally fetch fields
  • @skip(if: $var) - Conditionally skip fields

Critical Don'ts:

  • ❌ Don't paginate without @connection
  • ❌ Don't skip error handling in mutations
  • ❌ Don't forget interval cleanup
  • ❌ Don't mutate store records directly
  • ❌ Don't over-optimize prematurely

Remember:

  1. Start simple - Use basic patterns first, add complexity only when needed
  2. Colocate data - Keep fragments with their components
  3. Handle errors - Networks fail; plan for it
  4. Test optimistic updates - Verify rollback behavior
  5. Profile performance - Use React DevTools and Relay DevTools to identify bottlenecks

πŸ’‘ Pro Tip: Enable the Relay DevTools browser extension to visualize your store, inspect queries, and debug cache issues in real-time.

πŸ“š Further Study


πŸŽ‰ Congratulations! You've mastered advanced Relay patterns. These techniques will help you build fast, maintainable GraphQL applications that scale. Practice implementing these patterns in your projects, and you'll soon handle complex data requirements with confidence.