NexusCS

Relay Modern

React
Relay is Meta's GraphQL client for React with ahead-of-time compilation, data masking, and automatic cache updates. This guide covers Relay v16+.

Getting started

Setup

# Runtime dependencies
npm install relay-runtime react-relay

# Dev dependencies
npm install -D relay-compiler babel-plugin-relay graphql

# TypeScript types
npm install -D @types/relay-runtime @types/react-relay

relay.config.json:

{
  "src": "./src",
  "schema": "./schema.graphql",
  "language": "typescript"
}

Compiler commands:

npx relay-compiler           # Build once
npx relay-compiler --watch   # Watch mode (needs Watchman)

Environment Setup

import { Environment, Network, Store, RecordSource } from "relay-runtime";
import type { FetchFunction } from "relay-runtime";

const fetchFn: FetchFunction = async (request, variables) => {
  const resp = await fetch("/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: request.text,
      variables,
    }),
  });
  return await resp.json();
};

export const environment = new Environment({
  network: Network.create(fetchFn),
  store: new Store(new RecordSource()),
});

Provider

import { RelayEnvironmentProvider } from "react-relay";
import { environment } from "./environment";

function App() {
  return (
    <RelayEnvironmentProvider environment={environment}>
      <MainContent />
    </RelayEnvironmentProvider>
  );
}

Vite Config

// vite.config.ts
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react({ babel: { plugins: ["relay"] } })],
});

Fragments

useFragment

import { graphql, useFragment } from "react-relay";
import type { UserCard_user$key } from "./__generated__/UserCard_user.graphql";

type Props = { user: UserCard_user$key };

function UserCard({ user }: Props) {
  const data = useFragment(
    graphql`
      fragment UserCard_user on User {
        name
        avatar {
          uri
        }
      }
    `,
    user,
  );

  return <div>{data.name}</div>;
}

Fragment naming: ComponentName_propName

Fragment Composition

# Parent query spreads child fragment
query UserPageQuery($id: ID!) {
  user(id: $id) {
    ...UserCard_user
    ...UserPosts_user
  }
}

# Child fragments compose further
fragment UserCard_user on User {
  name
  ...UserAvatar_user
}

Fragment Arguments

fragment UserAvatar_user on User
@argumentDefinitions(size: { type: "Int", defaultValue: 100 }) {
  avatar(size: $size) {
    uri
  }
}

# Parent passes arguments
fragment UserCard_user on User {
  ...UserAvatar_user @arguments(size: 200)
}

Queries

usePreloadedQuery

import { graphql, usePreloadedQuery, loadQuery } from "react-relay";
import type { AppQuery } from "./__generated__/AppQuery.graphql";

const query = graphql`
  query AppQuery($id: ID!) {
    user(id: $id) {
      name
      ...UserCard_user
    }
  }
`;

// Preload early (router, event handler, etc.)
const queryRef = loadQuery(environment, query, { id: "4" });

// Read in component (suspends until ready)
function App({ queryRef }) {
  const data = usePreloadedQuery(query, queryRef);
  return <h1>{data.user?.name}</h1>;
}

⚠️ loadQuery refs must be .dispose()d when no longer needed.

useQueryLoader

import { useQueryLoader } from "react-relay";

function ProfileButton({ userId }) {
  const [queryRef, loadQuery] = useQueryLoader(ProfileQuery);

  return (
    <>
      <button onClick={() => loadQuery({ id: userId })}>View Profile</button>
      {queryRef && (
        <Suspense fallback={<Spinner />}>
          <ProfilePage queryRef={queryRef} />
        </Suspense>
      )}
    </>
  );
}

useLazyLoadQuery

import { graphql, useLazyLoadQuery } from "react-relay";

function App() {
  const data = useLazyLoadQuery(
    graphql`
      query AppQuery($id: ID!) {
        user(id: $id) {
          name
        }
      }
    `,
    { id: "4" },
    { fetchPolicy: "store-or-network" },
  );
  return <h1>{data.user?.name}</h1>;
}

⚠️ Not recommended — fetches during render, causes waterfalls. Prefer usePreloadedQuery.

Fetch Policies

Policy Behavior
store-or-network (default) Use cache, fetch if missing
store-and-network Use cache AND always fetch
network-only Always fetch, ignore cache
store-only Cache only, never fetch

Mutations

useMutation

import { graphql, useMutation } from "react-relay";

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

commit({
  variables: { input: { postId: "123" } },
  onCompleted: (response) => console.log("Done", response),
  onError: (error) => console.error(error),
});

Optimistic Updates

commit({
  variables: { input: { postId: "123" } },
  optimisticResponse: {
    like: {
      post: {
        id: "123",
        likeCount: currentCount + 1,
        viewerHasLiked: true,
      },
    },
  },
});

Updater Functions

import { ConnectionHandler } from "relay-runtime";

commit({
  variables: { input: { title: "New Post" } },
  updater: (store) => {
    // Get connection
    const conn = ConnectionHandler.getConnection(
      store.get(userId)!,
      "PostList_posts",
    );

    // Create and insert edge
    const newPost = store.getRootField("createPost")!.getLinkedRecord("post");
    const edge = ConnectionHandler.createEdge(
      store,
      conn!,
      newPost!,
      "PostEdge",
    );
    ConnectionHandler.insertEdgeAfter(conn!, edge);
  },
});

Mutation Directives

# Auto-delete record from store
mutation DeletePostMutation($id: ID!) {
  deletePost(id: $id) {
    deletedPostId @deleteRecord
  }
}

# Type-safe optimistic responses
mutation LikeMutation($input: LikeInput!)
  @raw_response_type {
  like(input: $input) { ... }
}

Pagination

usePaginationFragment

import { graphql, usePaginationFragment } from "react-relay";

const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
  graphql`
    fragment PostList_user on User
    @argumentDefinitions(
      count: { type: "Int", defaultValue: 10 }
      cursor: { type: "String" }
    )
    @refetchable(queryName: "PostListPaginationQuery") {
      posts(first: $count, after: $cursor) @connection(key: "PostList_posts") {
        edges {
          node {
            id
            title
          }
        }
      }
    }
  `,
  userRef,
);

return (
  <>
    {data.posts?.edges?.map((edge) => (
      <Post key={edge.node.id} post={edge.node} />
    ))}
    {hasNext && (
      <button onClick={() => loadNext(10)} disabled={isLoadingNext}>
        {isLoadingNext ? "Loading..." : "Load More"}
      </button>
    )}
  </>
);

Pagination API

Method Description
loadNext(count) Load next page
loadPrevious(count) Load previous page
hasNext More items forward?
hasPrevious More items backward?
isLoadingNext Loading forward?
isLoadingPrevious Loading backward?
refetch(vars) Refetch with new variables (suspends)

Required Directives

@argumentDefinitions(          # Declare pagination args
  count: { type: "Int", defaultValue: 10 }
  cursor: { type: "String" }
)
@refetchable(queryName: "...")  # Auto-generate refetch query
@connection(key: "...")         # Mark as paginated connection

Refetching

useRefetchableFragment

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

const [data, refetch] = useRefetchableFragment(
  graphql`
    fragment CommentBody_comment on Comment
    @refetchable(queryName: "CommentBodyRefetchQuery") {
      body(lang: $lang) {
        text
      }
    }
  `,
  commentRef,
);

<button onClick={() => refetch({ lang: "SPANISH" })}>Translate</button>;

Subscriptions

import { graphql, useSubscription } from "react-relay";
import { useMemo } from "react";

const config = useMemo(
  () => ({
    variables: { id: postId },
    subscription: graphql`
      subscription PostUpdatedSubscription($id: ID!) {
        postUpdated(id: $id) {
          id
          title
          likeCount
        }
      }
    `,
  }),
  [postId],
);

useSubscription(config);

⚠️ Config object MUST be memoized with useMemo.

Directives

Common Directives

Directive Purpose
@connection(key: "K") Mark paginated connection
@refetchable(queryName: "Q") Enable refetching
@argumentDefinitions(...) Declare fragment args
@arguments(...) Pass args to fragment
@relay(plural: true) Fragment expects array
@inline Read fragment outside render
@required(action: LOG/NONE/THROW) Handle null values
@catch Handle field-level errors
@deleteRecord Delete from store in mutation
@raw_response_type Type-safe optimistic updates
@relay(mask: false) ⚠️ Anti-pattern — use @inline

Store API

RecordProxy

// In updater functions
updater: (store) => {
  // Get records
  const record = store.get("id-123");
  const root = store.getRoot();
  const field = store.getRootField("createPost");

  // Read/write scalars
  record.getValue("name");
  record.setValue("New Name", "name");

  // Read/write linked records
  record.getLinkedRecord("author");
  record.setLinkedRecord(newAuthor, "author");

  // Read/write linked record lists
  record.getLinkedRecords("comments");
  record.setLinkedRecords(newComments, "comments");

  // Create/delete records
  store.create("new-id", "Post");
  store.delete("old-id");
};

ConnectionHandler

import { ConnectionHandler } from "relay-runtime";

// Get connection
const conn = ConnectionHandler.getConnection(record, "FeedList_posts", {
  orderBy: "DATE",
});

// Add edge
const edge = ConnectionHandler.createEdge(store, conn, newNode, "PostEdge");
ConnectionHandler.insertEdgeAfter(conn, edge);
ConnectionHandler.insertEdgeBefore(conn, edge);

// Remove node
ConnectionHandler.deleteNode(conn, nodeId);

Gotchas

  • Prefer usePreloadedQuery over useLazyLoadQuery (avoid render-time waterfalls)
  • loadQuery refs must be .dispose()d to prevent memory leaks
  • Pagination loadNext/loadPrevious do NOT suspend — use isLoadingNext/isLoadingPrevious
  • Subscription config must be wrapped in useMemo
  • Relay auto-selects id on Node types (no need to manually add)
  • Wrap components with <Suspense> for loading states
  • @relay(mask: false) is an anti-pattern — use @inline + readInlineData() instead
  • Spread component fragments in mutations for auto-updates instead of selecting fields manually

Also see