The features people don't know Relay has
@defer/@stream through graphql-js 17, streaming newline-delimited JSON that the Relay network layer feeds to the executor payload by payload.@defer: paint the fast part now, stream the slow part in
Mark a fragment spread with @defer and the server sends it as a later payload of the same response. The component reading it suspends on its own, so only that part of the UI shows a placeholder. No second query, no waterfall, no loading flags.
@stream: render list items as the server produces them
@stream(initialCount: 1) sends the first item with the initial payload and every later item as soon as its resolver yields. Relay appends each one to the same list record. For connections there's @stream_connection.
Server components: preload on the server, hydrate the store
This page is a React Server Component. It executed ServerPreloadedQuery in-process while rendering, then handed the raw payload to a client component that commits it to the Relay store before reading. The data is in the first HTML byte, and the client never refetches.
- Why colocated fragments scale
- The normalized cache, explained
- Optimistic UI without the tears
- Cursor pagination in practice
This list was in the HTML before any JavaScript ran. The like buttons still work: the server's payload was written into the client store, so mutations, the devtools drawer and every other page share it.
// app/advanced/page.tsx (server component)
import Query from "@/__generated__/ServerPreloadedQuery.graphql";
export default async function Page() {
const { response } = await executePersisted(Query);
return <ServerPreloaded response={response} />;
}
// ServerPreloaded.tsx ("use client")
const op = createOperationDescriptor(getRequest(query), {});
environment.commitPayload(op, response.data); // once
environment.retain(op); // keep it from GC
useLazyLoadQuery(query, {}, { fetchPolicy: "store-only" });@required & @catch: nullability you can reason about
GraphQL makes almost everything nullable so one failing field doesn't fail the whole response. Relay gives components two ways to say what they actually need, and the generated types follow.
More hidden gems
Not every feature needs a live demo to make the case. These are the ones teams discover late and wish they had known about earlier.
Model derived and client-only state as fields in the schema. Components read them through fragments like any server field, and live resolvers can subscribe to an external store.
/**
* @relayField User.initials: String
* @rootFragment UserInitialsFragment
*/
export function initials(key) { … }Data-driven dependencies: for a union or interface, the server picks which component renders each member, and Relay loads just that component's code with the data.
attachment {
...ImageAttachment_a @module(name: "ImageAttachment")
...VideoAttachment_a @module(name: "VideoAttachment")
}Declare a route's queries and its component together, then start loading both on hover or navigation, before React renders anything. Render-as-you-fetch at the route level.
const ref = loadEntryPoint(env, PostEntryPoint, { id });
<EntryPointContainer entryPointReference={ref} />A spread whose type might not match (a Post fragment on a Node field) must be aliased, so it becomes a nullable property instead of silently reading nothing. This app hit that compile error while being built.
node(id: $id) {
...PostCard_post @alias(as: "post")
}
// data.node.post: PostCard_post$key | nullOpt a fragment into throwing field errors to the nearest error boundary. Fields the schema marks @semanticNonNull (null only on error) then get non-null types.
# schema
type User { name: String @semanticNonNull }
fragment Profile_user on User @throwOnFieldError {
name # string, not string | null
}Read a fragment outside React (in a sort comparator, analytics call or utility) without subscribing or rendering, and keep the same masking and colocation.
const post = readInlineData(
graphql`fragment rank_post on Post @inline { likeCount }`,
ref,
);Update connections straight from the mutation response. No updater function and no store-traversal code.
addComment(input: $input) {
commentEdge @appendEdge(connections: $conns) { … }
}
deletePost(input: $i) { id @deleteRecord }When you do need to write to the store imperatively, @updatable fragments and queries give you a typed, assignable proxy instead of string-keyed record APIs.
const { updatableData } = store.readUpdatableQuery(q, {});
updatableData.viewer.name = "Ada";Relay reference-counts every query. Records nothing retains are collected, and invalidateStore() or invalidateRecord() mark data stale so the next read refetches.
new Store(source, { gcReleaseBufferSize: 10 });
commitLocalUpdate(env, s => s.invalidateStore());