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

Refetchable Fragments

Making fragments independently refetchable with @refetchable

Refetchable Fragments in Relay

Master Relay's data refetching patterns with free flashcards and spaced repetition practice. This lesson covers refetchable fragments, refetch queries, and fragment variables—essential concepts for building dynamic, interactive UIs with efficient data fetching in GraphQL applications.

Welcome to Refetchable Fragments 🔄

Welcome to one of Relay's most powerful features! Refetchable fragments allow you to reload specific parts of your component's data without refetching everything or navigating away from the page. Think of it as a surgical data refresh—you update exactly what you need, when you need it, keeping your UI responsive and your network usage minimal.

In this lesson, you'll learn how to make fragments refetchable, implement pagination, handle filtering and sorting, and master the patterns that make Relay applications feel lightning-fast and responsive.

Core Concepts 💡

What Makes a Fragment Refetchable?

A refetchable fragment is a fragment that can independently fetch new data based on updated variables. Unlike regular fragments that receive their data passively from parent queries, refetchable fragments generate their own refetch queries.

To make a fragment refetchable, you use the @refetchable directive:

fragment UserProfile_user on User
  @refetchable(queryName: "UserProfileRefetchQuery") {
  id
  name
  email
  posts(first: 10) {
    edges {
      node {
        title
      }
    }
  }
}

The queryName parameter tells Relay what to name the generated refetch query. Relay creates this query automatically—you never write it yourself.

The Anatomy of Refetchable Fragments 🔍

Let's break down what makes refetchable fragments special:

Component Purpose Required?
@refetchable directive Marks fragment as independently refetchable ✅ Yes
queryName Names the auto-generated refetch query ✅ Yes
id field Identifies the object to refetch (for Node interface) ✅ Usually yes
@argumentDefinitions Declares variables the fragment accepts ⚠️ For parameterized refetching

Fragment Variables and Arguments 📝

Refetchable fragments can accept variables that control what data gets fetched. You define these with @argumentDefinitions:

fragment PostList_user on User
  @refetchable(queryName: "PostListRefetchQuery")
  @argumentDefinitions(
    count: {type: "Int", defaultValue: 10}
    orderBy: {type: "PostOrder", defaultValue: RECENT}
  ) {
  id
  posts(first: $count, orderBy: $orderBy) {
    edges {
      node {
        id
        title
        createdAt
      }
    }
  }
}

These variables let you refetch with different parameters—more posts, different sorting, filtered results—without changing your fragment definition.

The useRefetchableFragment Hook 🎣

In your React component, you use useRefetchableFragment instead of useFragment:

import { useRefetchableFragment, graphql } from 'react-relay';

function PostList({ userRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment PostList_user on User
        @refetchable(queryName: "PostListRefetchQuery")
        @argumentDefinitions(
          count: {type: "Int", defaultValue: 10}
        ) {
        id
        posts(first: $count) {
          edges {
            node {
              id
              title
            }
          }
        }
      }
    `,
    userRef
  );

  const loadMore = () => {
    refetch({ count: 20 }); // Fetch 20 posts instead of 10
  };

  return (
    <div>
      {data.posts.edges.map(edge => (
        <div key={edge.node.id}>{edge.node.title}</div>
      ))}
      <button onClick={loadMore}>Load More</button>
    </div>
  );
}

The hook returns:

  1. data: The current fragment data
  2. refetch: A function to trigger a refetch with new variables

💡 Key Insight: The refetch function returns the data in-flight as a suspense resource, so you can use <Suspense> boundaries to show loading states.

Detailed Refetching Patterns 🔄

Pattern 1: Simple Refetch with Updated Variables

The most basic pattern—refetch the same fragment with different parameters:

function UserPosts({ userRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment UserPosts_user on User
        @refetchable(queryName: "UserPostsRefetchQuery")
        @argumentDefinitions(
          sortBy: {type: "PostSort", defaultValue: RECENT}
        ) {
        id
        username
        posts(first: 10, sortBy: $sortBy) {
          edges {
            node {
              id
              title
              likes
              createdAt
            }
          }
        }
      }
    `,
    userRef
  );

  const sortByRecent = () => refetch({ sortBy: 'RECENT' });
  const sortByPopular = () => refetch({ sortBy: 'POPULAR' });

  return (
    <div>
      <div>
        <button onClick={sortByRecent}>Recent</button>
        <button onClick={sortByPopular}>Popular</button>
      </div>
      {data.posts.edges.map(edge => (
        <article key={edge.node.id}>
          <h3>{edge.node.title}</h3>
          <span>❤️ {edge.node.likes}</span>
        </article>
      ))}
    </div>
  );
}

What happens: When you click "Popular", Relay generates and executes a query like:

query UserPostsRefetchQuery($id: ID!, $sortBy: PostSort!) {
  node(id: $id) {
    ...UserPosts_user @arguments(sortBy: $sortBy)
  }
}

Relay automatically:

  • Uses the id from your data to identify which user to refetch
  • Applies the new sortBy variable
  • Updates the store and re-renders your component

Pattern 2: Refetch with Suspense Boundaries

For better UX, wrap refetch operations in Suspense boundaries:

function PostList({ userRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment PostList_user on User
        @refetchable(queryName: "PostListRefetchQuery")
        @argumentDefinitions(
          category: {type: "String"}
        ) {
        id
        posts(first: 20, category: $category) {
          edges {
            node {
              id
              title
            }
          }
        }
      }
    `,
    userRef
  );

  const [isPending, startTransition] = useTransition();

  const filterByCategory = (category) => {
    startTransition(() => {
      refetch({ category });
    });
  };

  return (
    <div>
      <select 
        onChange={(e) => filterByCategory(e.target.value)}
        disabled={isPending}
      >
        <option value="">All</option>
        <option value="tech">Tech</option>
        <option value="design">Design</option>
      </select>
      
      <Suspense fallback={<Spinner />}>
        {isPending && <div className="loading-overlay">Updating...</div>}
        {data.posts.edges.map(edge => (
          <Post key={edge.node.id} post={edge.node} />
        ))}
      </Suspense>
    </div>
  );
}

💡 Pro Tip: Using startTransition keeps your UI responsive during refetches. The old data stays visible while new data loads in the background.

Pattern 3: Pagination with Refetchable Fragments

While Relay has specialized hooks for pagination (usePaginationFragment), you can implement cursor-based pagination with refetchable fragments:

function InfinitePostList({ userRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment InfinitePostList_user on User
        @refetchable(queryName: "InfinitePostListRefetchQuery")
        @argumentDefinitions(
          count: {type: "Int", defaultValue: 10}
          after: {type: "String"}
        ) {
        id
        posts(first: $count, after: $after) {
          edges {
            node {
              id
              title
            }
            cursor
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
    userRef
  );

  const loadMore = () => {
    const { endCursor } = data.posts.pageInfo;
    refetch({ 
      count: 10, 
      after: endCursor 
    });
  };

  return (
    <div>
      {data.posts.edges.map(edge => (
        <div key={edge.node.id}>{edge.node.title}</div>
      ))}
      {data.posts.pageInfo.hasNextPage && (
        <button onClick={loadMore}>Load More</button>
      )}
    </div>
  );
}

⚠️ Important: This refetches and replaces the data. For true infinite scroll that appends results, use usePaginationFragment instead.

Practical Examples 🛠️

Example 1: Search Filter with Debouncing

A real-world pattern combining refetchable fragments with search:

import { useState, useEffect } from 'react';
import { useRefetchableFragment, graphql } from 'react-relay';
import { useDebouncedCallback } from 'use-debounce';

function SearchableUserList({ queryRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment SearchableUserList_query on Query
        @refetchable(queryName: "SearchableUserListRefetchQuery")
        @argumentDefinitions(
          searchTerm: {type: "String", defaultValue: ""}
          first: {type: "Int", defaultValue: 20}
        ) {
        users(search: $searchTerm, first: $first) {
          edges {
            node {
              id
              name
              email
            }
          }
        }
      }
    `,
    queryRef
  );

  const [searchInput, setSearchInput] = useState('');

  // Debounce refetch to avoid excessive network requests
  const debouncedRefetch = useDebouncedCallback((term) => {
    refetch({ searchTerm: term });
  }, 300);

  useEffect(() => {
    debouncedRefetch(searchInput);
  }, [searchInput, debouncedRefetch]);

  return (
    <div>
      <input
        type="text"
        placeholder="Search users..."
        value={searchInput}
        onChange={(e) => setSearchInput(e.target.value)}
      />
      <Suspense fallback={<div>Searching...</div>}>
        <ul>
          {data.users.edges.map(edge => (
            <li key={edge.node.id}>
              {edge.node.name} ({edge.node.email})
            </li>
          ))}
        </ul>
      </Suspense>
    </div>
  );
}

Key techniques:

  • ✅ Debouncing prevents refetch on every keystroke
  • ✅ Suspense shows loading state during search
  • ✅ Fragment manages its own search state
  • ✅ Parent component doesn't need to know about refetch logic

Example 2: Multi-Filter Product Catalog

Combining multiple filter criteria:

function ProductCatalog({ storeRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment ProductCatalog_store on Store
        @refetchable(queryName: "ProductCatalogRefetchQuery")
        @argumentDefinitions(
          category: {type: "String"}
          minPrice: {type: "Float"}
          maxPrice: {type: "Float"}
          inStock: {type: "Boolean", defaultValue: false}
          sortBy: {type: "ProductSort", defaultValue: POPULAR}
        ) {
        id
        products(
          category: $category
          minPrice: $minPrice
          maxPrice: $maxPrice
          inStock: $inStock
          sortBy: $sortBy
          first: 50
        ) {
          edges {
            node {
              id
              name
              price
              stockCount
              imageUrl
            }
          }
        }
      }
    `,
    storeRef
  );

  const [filters, setFilters] = useState({
    category: null,
    minPrice: null,
    maxPrice: null,
    inStock: false,
    sortBy: 'POPULAR'
  });

  const updateFilters = (newFilters) => {
    setFilters(prev => ({ ...prev, ...newFilters }));
    refetch({ ...filters, ...newFilters });
  };

  return (
    <div className="catalog">
      <aside className="filters">
        <select 
          value={filters.category || ''}
          onChange={(e) => updateFilters({ category: e.target.value || null })}
        >
          <option value="">All Categories</option>
          <option value="electronics">Electronics</option>
          <option value="clothing">Clothing</option>
        </select>

        <label>
          <input
            type="checkbox"
            checked={filters.inStock}
            onChange={(e) => updateFilters({ inStock: e.target.checked })}
          />
          In Stock Only
        </label>

        <select
          value={filters.sortBy}
          onChange={(e) => updateFilters({ sortBy: e.target.value })}
        >
          <option value="POPULAR">Popular</option>
          <option value="PRICE_LOW">Price: Low to High</option>
          <option value="PRICE_HIGH">Price: High to Low</option>
        </select>
      </aside>

      <main className="products">
        <Suspense fallback={<div>Loading products...</div>}>
          {data.products.edges.map(edge => (
            <div key={edge.node.id} className="product-card">
              <img src={edge.node.imageUrl} alt={edge.node.name} />
              <h3>{edge.node.name}</h3>
              <p>${edge.node.price}</p>
              <p>{edge.node.stockCount > 0 ? '✅ In Stock' : '❌ Out of Stock'}</p>
            </div>
          ))}
        </Suspense>
      </main>
    </div>
  );
}

Example 3: Real-time Refresh Button

Manual refresh for time-sensitive data:

function LiveStockTicker({ stockRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment LiveStockTicker_stock on Stock
        @refetchable(queryName: "LiveStockTickerRefetchQuery") {
        id
        symbol
        currentPrice
        change
        changePercent
        lastUpdated
      }
    `,
    stockRef
  );

  const [isRefreshing, setIsRefreshing] = useState(false);

  const handleRefresh = () => {
    setIsRefreshing(true);
    refetch(
      {}, // No variable changes, just refetch current state
      {
        fetchPolicy: 'network-only', // Force fresh data from server
        onComplete: () => setIsRefreshing(false)
      }
    );
  };

  const changeColor = data.change >= 0 ? 'green' : 'red';
  const changeIcon = data.change >= 0 ? '📈' : '📉';

  return (
    <div className="stock-ticker">
      <h2>{data.symbol}</h2>
      <div className="price">${data.currentPrice.toFixed(2)}</div>
      <div style={{ color: changeColor }}>
        {changeIcon} {data.change.toFixed(2)} ({data.changePercent.toFixed(2)}%)
      </div>
      <small>Last updated: {new Date(data.lastUpdated).toLocaleTimeString()}</small>
      <button 
        onClick={handleRefresh} 
        disabled={isRefreshing}
      >
        {isRefreshing ? '🔄 Refreshing...' : '🔄 Refresh'}
      </button>
    </div>
  );
}

💡 Fetch Policy Options:

  • store-or-network: Use cached data if available (default)
  • network-only: Always fetch fresh data from server
  • store-only: Never hit network, only use cache

Example 4: Conditional Refetching Based on User Action

Refetch different data based on tab selection:

function UserDashboard({ userRef }) {
  const [data, refetch] = useRefetchableFragment(
    graphql`
      fragment UserDashboard_user on User
        @refetchable(queryName: "UserDashboardRefetchQuery")
        @argumentDefinitions(
          showDrafts: {type: "Boolean", defaultValue: false}
          showArchived: {type: "Boolean", defaultValue: false}
        ) {
        id
        name
        posts(
          includeDrafts: $showDrafts
          includeArchived: $showArchived
          first: 20
        ) {
          edges {
            node {
              id
              title
              status
            }
          }
        }
      }
    `,
    userRef
  );

  const [activeTab, setActiveTab] = useState('published');

  const switchTab = (tab) => {
    setActiveTab(tab);
    switch(tab) {
      case 'published':
        refetch({ showDrafts: false, showArchived: false });
        break;
      case 'drafts':
        refetch({ showDrafts: true, showArchived: false });
        break;
      case 'archived':
        refetch({ showDrafts: false, showArchived: true });
        break;
      case 'all':
        refetch({ showDrafts: true, showArchived: true });
        break;
    }
  };

  return (
    <div>
      <h1>Welcome, {data.name}! 👋</h1>
      <nav>
        <button 
          onClick={() => switchTab('published')}
          className={activeTab === 'published' ? 'active' : ''}
        >
          📄 Published
        </button>
        <button 
          onClick={() => switchTab('drafts')}
          className={activeTab === 'drafts' ? 'active' : ''}
        >
          ✏️ Drafts
        </button>
        <button 
          onClick={() => switchTab('archived')}
          className={activeTab === 'archived' ? 'active' : ''}
        >
          📦 Archived
        </button>
        <button 
          onClick={() => switchTab('all')}
          className={activeTab === 'all' ? 'active' : ''}
        >
          🗂️ All
        </button>
      </nav>
      <Suspense fallback={<div>Loading posts...</div>}>
        <ul>
          {data.posts.edges.map(edge => (
            <li key={edge.node.id}>
              {edge.node.title} <span>({edge.node.status})</span>
            </li>
          ))}
        </ul>
      </Suspense>
    </div>
  );
}

Common Mistakes ⚠️

Mistake 1: Forgetting the ID Field

Wrong:

fragment UserProfile_user on User
  @refetchable(queryName: "UserProfileRefetchQuery") {
  # Missing id!
  name
  email
}

Correct:

fragment UserProfile_user on User
  @refetchable(queryName: "UserProfileRefetchQuery") {
  id  # Required for refetching!
  name
  email
}

Why: Relay needs the id field to know which specific object to refetch using the node(id: $id) pattern.

Mistake 2: Mutating Variables Object

Wrong:

const [filters, setFilters] = useState({ category: 'all' });

const updateCategory = (category) => {
  filters.category = category; // Mutation!
  refetch(filters);
};

Correct:

const [filters, setFilters] = useState({ category: 'all' });

const updateCategory = (category) => {
  const newFilters = { ...filters, category }; // Immutable
  setFilters(newFilters);
  refetch(newFilters);
};

Why: React state should be treated as immutable. Always create new objects.

Mistake 3: Not Handling Pending States

Wrong:

const handleRefetch = () => {
  refetch({ category: 'tech' });
  // UI doesn't show anything is loading
};

Correct:

const [isPending, startTransition] = useTransition();

const handleRefetch = () => {
  startTransition(() => {
    refetch({ category: 'tech' });
  });
};

// In JSX:
{isPending && <Spinner />}

Why: Users need feedback during data fetches. Use transitions or suspense boundaries.

Mistake 4: Refetching Too Frequently

Wrong:

<input 
  onChange={(e) => refetch({ search: e.target.value })} 
  // Refetches on every keystroke!
/>

Correct:

const debouncedRefetch = useDebouncedCallback(
  (term) => refetch({ search: term }),
  300
);

<input 
  onChange={(e) => debouncedRefetch(e.target.value)} 
  // Waits 300ms after typing stops
/>

Why: Excessive network requests waste resources and may trigger rate limits.

Mistake 5: Incorrect Variable Types in @argumentDefinitions

Wrong:

@argumentDefinitions(
  count: {type: "Number", defaultValue: 10}  # Wrong type!
)

Correct:

@argumentDefinitions(
  count: {type: "Int", defaultValue: 10}  # Use GraphQL types
)

Why: Variable types must match GraphQL schema types exactly (Int, String, Boolean, Float, etc.).

Mistake 6: Using Wrong Hook

Wrong:

const data = useFragment(
  graphql`
    fragment MyComponent_data on User
      @refetchable(queryName: "MyRefetchQuery") {
      id
      name
    }
  `,
  dataRef
);
// Can't access refetch function!

Correct:

const [data, refetch] = useRefetchableFragment(
  graphql`
    fragment MyComponent_data on User
      @refetchable(queryName: "MyRefetchQuery") {
      id
      name
    }
  `,
  dataRef
);
// Now you can use refetch

Why: The @refetchable directive requires useRefetchableFragment, not useFragment.

Key Takeaways 🎯

  1. The @refetchable directive transforms regular fragments into independently refetchable components with their own query generation.

  2. useRefetchableFragment hook returns both data and a refetch function, giving components control over their data fetching.

  3. Fragment variables declared with @argumentDefinitions allow parameterized refetching—sorting, filtering, pagination, and more.

  4. Always include id field when using @refetchable on object types that implement the Node interface.

  5. Combine with Suspense and transitions for smooth, responsive UX during refetches.

  6. Debounce frequent refetches (like search inputs) to reduce network load and improve performance.

  7. Fetch policies control caching behavior: store-or-network (default), network-only (force fresh), store-only (cache only).

  8. Refetch replaces data, it doesn't append—use usePaginationFragment for infinite scroll patterns.

Quick Reference Card 📋

🔄 Refetchable Fragment Cheat Sheet

Concept Syntax Purpose
Make refetchable @refetchable(queryName: "Name") Enable independent refetching
Define variables @argumentDefinitions(var: {type: "Int"}) Accept parameters for refetch
Use in component useRefetchableFragment(fragment, ref) Get data + refetch function
Trigger refetch refetch({ newVar: value }) Fetch with updated variables
Force fresh data refetch({}, { fetchPolicy: 'network-only' }) Bypass cache entirely
Smooth transitions startTransition(() => refetch({})) Keep UI responsive
Required field id Identify object to refetch

Common Patterns:

  • 🔍 Search: Debounce + refetch with search term
  • 🎛️ Filters: Combine multiple variables in refetch
  • 🔄 Refresh: Force network-only fetch
  • 📑 Tabs: Refetch with different boolean flags
  • ⏳ Loading: Wrap in Suspense or use transitions

Try This: Build a Filterable List 🔧

Practice what you've learned:

  1. Create a fragment with @refetchable and @argumentDefinitions for filtering
  2. Implement multiple filter controls (dropdown, checkbox, search)
  3. Add debouncing to search input
  4. Use startTransition for smooth filter updates
  5. Display loading states with Suspense
  6. Add a "Reset Filters" button

Bonus Challenge: Implement "Save Filter Preset" functionality that stores filter combinations in local state.

📚 Further Study


🎉 Congratulations! You now understand how to make fragments refetchable, control data fetching with variables, and build dynamic, responsive UIs with Relay. Next up: explore pagination patterns with usePaginationFragment for even more powerful data management!