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

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:

  1. An id field that returns a globally unique, non-null ID
  2. 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:

  1. We query using node(id: $id) with a global ID
  2. Relay automatically determines it's a Post from the cache
  3. The refetch uses the same node query to get fresh data
  4. 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 πŸ“Œ

  1. Global IDs are mandatory for Relay's cache to work correctly
  2. Encode type information in the ID using TypeName:localId pattern
  3. Implement the node query at the root to enable refetching
  4. Use DataLoader to batch and cache database queries
  5. Every cacheable type should implement Node
  6. Return null gracefully when objects aren't found
  7. 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