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
usePreloadedQueryoveruseLazyLoadQuery(avoid render-time waterfalls) loadQueryrefs must be.dispose()d to prevent memory leaks- Pagination
loadNext/loadPreviousdo NOT suspend — useisLoadingNext/isLoadingPrevious - Subscription config must be wrapped in
useMemo - Relay auto-selects
idon 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
- Relay docs (relay.dev)
- API reference (relay.dev)
- GraphQL spec (graphql.org)