Node Interface Requirements
Implementing the id field and node interface on your GraphQL types
Node Interface Requirements
Master the Node interface in Relay with free flashcards and spaced repetition practice. This lesson covers the global object identification pattern, implementing the Node interface in GraphQL schemas, and using node queries for efficient data fetchingβessential concepts for building scalable Relay applications.
Welcome π
In Relay's architecture, the Node interface serves as the cornerstone for global object identification. This powerful pattern enables Relay to uniquely identify any object across your entire GraphQL schema, regardless of its type. By implementing the Node interface correctly, you unlock Relay's most sophisticated features: automatic cache normalization, efficient refetching, and seamless data consistency across your application.
π‘ Why does this matter? Without proper Node implementation, Relay cannot maintain its cache effectively, leading to duplicate data, inconsistent UI states, and unnecessary network requests. The Node interface is not optionalβit's fundamental to how Relay works.
Core Concepts π
What is the Node Interface?
The Node interface is a GraphQL interface that requires exactly two things:
- An
idfield that returns a globally unique, non-null ID - Implementation by any type that needs to be refetchable and cacheable by Relay
Here's the basic schema definition:
interface Node {
id: ID!
}
Every type implementing Node must provide this id field. But there's more to it than just adding a fieldβthe ID must follow specific rules to work with Relay's caching system.
The Global ID Pattern π
Relay requires globally unique identifiers that encode both:
- The type of the object (e.g., "User", "Post", "Comment")
- The internal ID used by your backend database
A typical global ID looks like this when base64-decoded:
User:123
Post:456
Comment:789
When base64-encoded (as required by convention):
VXNlcjoxMjM=
UG9zdDo0NTY=
Q29tbWVudDo3ODk=
Why this pattern?
- β Prevents ID collisions between different types
- β Allows Relay to cache objects efficiently
- β Enables type-safe refetching
- β Makes debugging easier (you can decode IDs to see what they represent)
Schema Requirements π
Every GraphQL schema used with Relay must implement:
1. The Node Interface Definition
interface Node {
id: ID!
}
2. A Root-Level node Query Field
type Query {
node(id: ID!): Node
# ... other query fields
}
This node field is crucialβit allows Relay to refetch any object by its global ID, regardless of type.
3. Types Implementing Node
type User implements Node {
id: ID!
name: String!
email: String!
}
type Post implements Node {
id: ID!
title: String!
content: String!
author: User!
}
type Comment implements Node {
id: ID!
text: String!
author: User!
post: Post!
}
β οΈ Critical: Every type that Relay needs to cache and refetch must implement Node. If a type doesn't implement Node, Relay cannot normalize it in the cache.
Resolver Implementation π»
The backend must implement two key resolvers:
1. The node Query Resolver
This resolver takes a global ID, decodes it, determines the type, and fetches the object:
// Node.js example with graphql-relay
const { fromGlobalId } = require('graphql-relay');
const resolvers = {
Query: {
node: async (parent, { id }, context) => {
// Decode the global ID
const { type, id: localId } = fromGlobalId(id);
// Route to appropriate data loader
switch (type) {
case 'User':
return context.userLoader.load(localId);
case 'Post':
return context.postLoader.load(localId);
case 'Comment':
return context.commentLoader.load(localId);
default:
return null;
}
}
}
};
2. The id Field Resolver for Each Type
Each type implementing Node must resolve its id field to a global ID:
const { toGlobalId } = require('graphql-relay');
const resolvers = {
User: {
id: (user) => toGlobalId('User', user.id)
},
Post: {
id: (post) => toGlobalId('Post', post.id)
},
Comment: {
id: (comment) => toGlobalId('Comment', comment.id)
}
};
π‘ Pro Tip: Use DataLoader to batch and cache database queries in your node resolver. This prevents N+1 query problems when Relay refetches multiple objects.
The Node Resolution Flow π
Here's what happens when Relay queries a node:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β RELAY NODE QUERY FLOW β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
π₯οΈ Client (Relay)
|
| query { node(id: "VXNlcjoxMjM=") {
| ... on User { name }
| }}
β
π GraphQL Server
|
| 1. Decode ID β type="User", id="123"
β
π Node Resolver
|
| 2. Switch on type
| 3. Call userLoader.load("123")
β
πΎ Database
|
| 4. SELECT * FROM users WHERE id=123
β
π¦ User Object
|
| 5. Resolve fields (name, etc.)
| 6. Encode id β "VXNlcjoxMjM="
β
π€ Response to Client
|
| { node: { id: "VXNlcjoxMjM=", name: "Alice" }}
β
πΎ Relay Cache (Normalized)
Interface Polymorphism π
The Node interface enables powerful polymorphic queries. You can fetch different types through the same query:
query PolymorphicNodeQuery {
node(id: $someId) {
id # Available on all Node types
__typename # Important for type discrimination
... on User {
name
email
}
... on Post {
title
content
}
... on Comment {
text
}
}
}
Relay uses __typename to determine which type-specific fields to read from the cache. This is automaticβyou don't need to request __typename explicitly in most cases.
When to Implement Node βοΈ
β DO implement Node for:
- Entities that appear in multiple places in your UI
- Objects that users can navigate to directly (detail pages)
- Types that need to be refetchable independently
- Entities with relationships to other entities
- Any type you want Relay to normalize in its cache
β DON'T implement Node for:
- Simple scalar-like types (e.g.,
type Coordinates { lat: Float, lng: Float }) - Embedded value objects that don't have independent existence
- Types that are always fetched as part of their parent
- Enum-like types that represent fixed sets of values
- Temporary or transient data structures
π‘ Rule of Thumb: If you have a database table with a primary key for it, it probably should implement Node.
Examples π οΈ
Example 1: Basic Node Implementation
Let's build a complete Node implementation for a blog platform:
Schema Definition:
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
viewer: User
}
type User implements Node {
id: ID!
username: String!
email: String!
posts: [Post!]!
}
type Post implements Node {
id: ID!
title: String!
content: String!
author: User!
createdAt: String!
}
Backend Implementation (Node.js):
const {
toGlobalId,
fromGlobalId,
nodeDefinitions
} = require('graphql-relay');
const DataLoader = require('dataloader');
// Create data loaders
const createLoaders = (db) => ({
userLoader: new DataLoader(async (ids) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[ids]
);
return ids.map(id => users.find(u => u.id === id));
}),
postLoader: new DataLoader(async (ids) => {
const posts = await db.query(
'SELECT * FROM posts WHERE id = ANY($1)',
[ids]
);
return ids.map(id => posts.find(p => p.id === id));
})
});
// Node definitions
const { nodeInterface, nodeField } = nodeDefinitions(
// Fetcher: given a global ID, return the object
async (globalId, context) => {
const { type, id } = fromGlobalId(globalId);
switch (type) {
case 'User':
return context.loaders.userLoader.load(id);
case 'Post':
return context.loaders.postLoader.load(id);
default:
return null;
}
},
// Type resolver: given an object, return its GraphQL type
(obj) => {
if (obj.username) return 'User';
if (obj.title && obj.content) return 'Post';
return null;
}
);
// Resolvers
const resolvers = {
Query: {
node: nodeField
},
User: {
id: (user) => toGlobalId('User', user.id),
posts: async (user, args, context) => {
const posts = await context.db.query(
'SELECT * FROM posts WHERE author_id = $1',
[user.id]
);
return posts;
}
},
Post: {
id: (post) => toGlobalId('Post', post.id),
author: (post, args, context) =>
context.loaders.userLoader.load(post.author_id)
}
};
Client Usage (Relay):
import { graphql, useFragment } from 'react-relay';
const UserProfileFragment = graphql`
fragment UserProfile_user on User {
id # This is the global ID
username
email
posts {
id
title
}
}
`;
function UserProfile({ userRef }) {
const user = useFragment(UserProfileFragment, userRef);
return (
<div>
<h1>{user.username}</h1>
<p>{user.email}</p>
<h2>Posts:</h2>
{user.posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
</div>
))}
</div>
);
}
Example 2: Refetching with Node Query
One of Node's superpowers is enabling refetching of any object by its ID:
import { graphql, useLazyLoadQuery, useRefetchableFragment } from 'react-relay';
const PostRefetchQuery = graphql`
query PostRefetchQuery($id: ID!) {
node(id: $id) {
...PostDetail_post
}
}
`;
const PostDetailFragment = graphql`
fragment PostDetail_post on Post @refetchable(queryName: "PostDetailRefetchQuery") {
id
title
content
author {
id
username
}
createdAt
}
`;
function PostDetail({ postId }) {
const data = useLazyLoadQuery(PostRefetchQuery, { id: postId });
const [post, refetch] = useRefetchableFragment(PostDetailFragment, data.node);
const handleRefresh = () => {
refetch({}, { fetchPolicy: 'network-only' });
};
return (
<div>
<button onClick={handleRefresh}>π Refresh</button>
<h1>{post.title}</h1>
<p>By {post.author.username}</p>
<div>{post.content}</div>
<small>{post.createdAt}</small>
</div>
);
}
What's happening here:
- We query using
node(id: $id)with a global ID - Relay automatically determines it's a Post from the cache
- The refetch uses the same node query to get fresh data
- Relay updates the cache and all components using this Post
Example 3: Composite Node Implementation
Sometimes you need to implement Node for types that don't have a simple database ID:
type Friendship implements Node {
id: ID! # Composite: "user1_id:user2_id"
user1: User!
user2: User!
status: FriendshipStatus!
createdAt: String!
}
enum FriendshipStatus {
PENDING
ACCEPTED
BLOCKED
}
Backend Implementation:
const resolvers = {
Friendship: {
id: (friendship) => {
// Create composite ID from both user IDs
const compositeId = `${friendship.user1_id}:${friendship.user2_id}`;
return toGlobalId('Friendship', compositeId);
},
user1: (friendship, args, context) =>
context.loaders.userLoader.load(friendship.user1_id),
user2: (friendship, args, context) =>
context.loaders.userLoader.load(friendship.user2_id)
}
};
// Node resolver for Friendship
const nodeFetcher = async (globalId, context) => {
const { type, id } = fromGlobalId(globalId);
if (type === 'Friendship') {
const [user1_id, user2_id] = id.split(':');
const friendship = await context.db.query(
'SELECT * FROM friendships WHERE user1_id = $1 AND user2_id = $2',
[user1_id, user2_id]
);
return friendship[0];
}
// ... other types
};
Example 4: Node Interface with Interfaces
You can have types that implement multiple interfaces, including Node:
interface Node {
id: ID!
}
interface Timestamped {
createdAt: String!
updatedAt: String!
}
type Article implements Node & Timestamped {
id: ID!
title: String!
content: String!
author: User!
createdAt: String!
updatedAt: String!
}
type Video implements Node & Timestamped {
id: ID!
title: String!
url: String!
duration: Int!
creator: User!
createdAt: String!
updatedAt: String!
}
This allows polymorphic queries across multiple dimensions:
query GetNodeAndTimestamp($id: ID!) {
node(id: $id) {
id # From Node
... on Timestamped {
createdAt
updatedAt
}
... on Article {
title
content
}
... on Video {
title
url
duration
}
}
}
Common Mistakes β οΈ
β Mistake 1: Using Database IDs Directly
Wrong:
type User implements Node {
id: ID! # Returns raw database ID like "123"
name: String!
}
const resolvers = {
User: {
id: (user) => user.id // β Not a global ID!
}
};
Why it fails: Relay can't distinguish between User:123 and Post:123, causing cache collisions.
Correct:
const resolvers = {
User: {
id: (user) => toGlobalId('User', user.id) // β
Global ID
}
};
β Mistake 2: Forgetting the Root node Field
Wrong:
type Query {
user(id: ID!): User
post(id: ID!): Post
# β No node field!
}
Why it fails: Relay's refetching mechanism requires the node query. Without it, useRefetchableFragment and automatic refetching won't work.
Correct:
type Query {
node(id: ID!): Node # β
Required!
user(id: ID!): User
post(id: ID!): Post
}
β Mistake 3: Inconsistent Type Names
Wrong:
// Schema uses "User"
type User implements Node { ... }
// But resolver uses "UserType"
const resolvers = {
User: {
id: (user) => toGlobalId('UserType', user.id) // β Mismatch!
}
};
Why it fails: When decoding UserType:123, your node resolver won't find the correct type.
Correct:
const resolvers = {
User: {
id: (user) => toGlobalId('User', user.id) // β
Matches schema
}
};
β Mistake 4: Not Implementing Node for Cached Types
Wrong:
type Post {
# β No Node implementation
title: String!
author: User!
}
If you query Posts in multiple places, Relay can't normalize them properly without the id field from Node.
Correct:
type Post implements Node {
id: ID! # β
Now Relay can normalize
title: String!
author: User!
}
β Mistake 5: Null Node Query Results
Wrong:
const resolvers = {
Query: {
node: (parent, { id }, context) => {
const { type, id: localId } = fromGlobalId(id);
// β Throws error if type not found
return context[`${type}Loader`].load(localId);
}
}
};
Correct:
const resolvers = {
Query: {
node: (parent, { id }, context) => {
const { type, id: localId } = fromGlobalId(id);
// β
Handle unknown types gracefully
const loaderName = `${type.toLowerCase()}Loader`;
const loader = context.loaders[loaderName];
if (!loader) {
console.warn(`No loader found for type: ${type}`);
return null;
}
return loader.load(localId);
}
}
};
π‘ Best Practice: The node query should return null for invalid IDs or deleted objects, not throw errors.
Key Takeaways π―
π Quick Reference: Node Interface Essentials
| Requirement | Implementation |
|---|---|
| Schema Interface | interface Node { id: ID! } |
| Root Query | node(id: ID!): Node |
| Global ID Format | Base64-encoded TypeName:localId |
| Encoding Function | toGlobalId(typeName, localId) |
| Decoding Function | fromGlobalId(globalId) |
| Type Resolution | Use __typename or type resolver |
| Caching Benefit | Automatic normalization by global ID |
| Refetching | Enabled via @refetchable directive |
π§ Memory Device: "Node = ID + node query"
Every type implementing Node needs a global ID field, and your schema needs a root node query to fetch any object by that ID.
Essential Points π
- Global IDs are mandatory for Relay's cache to work correctly
- Encode type information in the ID using
TypeName:localIdpattern - Implement the
nodequery at the root to enable refetching - Use DataLoader to batch and cache database queries
- Every cacheable type should implement Node
- Return null gracefully when objects aren't found
- Keep type names consistent between schema and ID encoding
When You've Mastered This π
You'll know you understand Node interface requirements when you can:
- β Implement Node for any GraphQL type
- β Write a proper node query resolver with type routing
- β Debug cache issues by inspecting global IDs
- β Use node queries for efficient refetching
- β Decide which types need Node implementation
- β Handle composite IDs for complex relationships
π Further Study
- Relay Documentation: GraphQL Server Specification - Official Relay requirements for Node interface
- GraphQL Relay Specification - The formal spec for global object identification
- DataLoader GitHub - Batching and caching utility for Node resolvers