A deep dive into the Relay store

Yash Mahalwal
26 min readMar 22, 2020
Photo by Patrick Tomasso on Unsplash

Facebook has done some really neat stuff with Relay in the past. And they are working on it rapidly. When I started writing this article, Relay was in v8. And before I can finish it, Relay is in v9. It is worth checking out the number of versions that came out in the last 12 months. Over the “versions”, they have added some great functionality. For example, the experimental version has hooks and enhanced suspense integration that works with React’s concurrent mode. Unfortunately, the people at Facebook haven’t put much effort into documenting Relay properly. The examples in the documentation are broken at places and the documentation itself is barely complete. It skips a lot of important stuff like adding custom scalars to Relay compiler and a proper explanation of the Relay store.

A screenshot of the relay website
A screenshot of the Relay Website

This is my attempt to explore the Relay store. Most of the content here is based on my experience with Relay at work and in personal projects.

Who is this article for ?

This article is targeted at people who can use Relay but have a hard time working with the Relay store. The prerequisites for this article are:

  1. ES6modules and newer operators like optional chaining and nullish coalescing. They are just fancy names for relatively simple operators. They are well supported in TypeScript 3.8
  2. Knowledge of GraphQL
  3. Basic experience with Relay
  4. [Optional] Typescript — basics and a little bit of generic types

To understand this article, you should be able to work with Relay. You should be able to write queries & mutations and should be able to use various containers. You should also be familiar with specifications enforced by Relay — Global Object Identification and Pagination Spec. You can find most of this stuff in Relay docs. Lastly, you need to have a clear understanding of updater functions.

How to read this article ?

This article has 5 sections in total. The first section introduces the Relay store. The second section is a discussion that sets a scene. All of my examples are based on that discussion. The later sections cover various methods required to work with the Relay store.

To begin with, you should cover the next two sections. As for the later sections, it can be tiresome to read them all in one go. So feel free to skip over sections and jump to the part you are interested in. I suggest a recursive traversal strategy. It involves the following steps:

  1. Start with the section you want to read.
  2. If at some point, that section uses contents from another section without explaining it properly, refer to the other section.
  3. Repeat the above steps until you understand everything you come across.

Also, remember that the TypeScript part is not essential to understand the basics of the store. You can just simply skip over the typescript stuff and read how methods work.

Note: While I do encourage you to read the article selectively, there are some circularly dependent topics. I have explained this wherever necessary but I still advise you to read with caution.

1. What is the Relay store ?

A GraphQL operation returns some data payload. The operation here can be a query, mutation or subscription. That payload is available with Relay whose job is to send that data to the appropriate components. Relay stores all the data in a central location called “store”. The Relay store is a data structure responsible for storing all the GraphQL data in your app.

Note: For Relay store to work properly, your schema must be Relay Compliant. That is, it should follow the Global Object Identification pattern. Each basic type must have a unique ID. Also, for better pagination experience, all pagination should be as per the relay spec.

The store remembers your operations and the GraphQL objects returned by those operations. If you were to go into the store object, you’d see this

A screenshot of the relay store object in browser console
The Relay store object

Wondering how I got the store into the console? I’ll give you a hint.

updater(store) => {window["store"] = store; ...}

Too much to take in, isn’t it? Thankfully, we have Relay DevTools which help you to explore the store in a better way.

A screenshot of Relay DevTools
The Relay DevTools

That being said, the Relay DevTools is pretty buggy and is not always reliable. But something is better than nothing, right?

1.1 Store vs Cache

Store simply keeps the data returned from your GraphQL operations. Say that you run a query again. In that case, the data is fetched again and put in the store. Store does not act as a cache. It is simply a location for all your GraphQL data.

You can add a cache to the network layer. In that case (till the cache does not expire) your query will be fired but instead of getting data from the server, the data is fetched from the local cache and put in the store.

1.2 Why would I need to use the store ?

If you want to update the client side data as a result of a GraphQL operation, you need to mutate the store. Relay makes sure that the new data reaches wherever it should.

Imagine that we have an application. It has a page where I can update the user’s data. To perform the update, I fire a mutation telling the server what to change. Then I take the payload from that mutation and update the current user in the store with that payload. And voila! My app now has updated information for that user everywhere.

Store is updated as a result of mutations and subscriptions. Relay provides special functions called updater functions to deal with the store. This function receives the store instance as an argument. You can perform all your store manipulations inside an updater function. Relay ensures that the changes are reflected wherever needed (you might also find a discussion on reactivity interesting).

2. A basic scenario

Imagine an online quizzing application (Like the one in the screenshots). Here is a basic schema for the app.

The GraphQL schema

Our discussion will revolve around this schema. Next, we set up an initial state. When the app renders, let us run the following query

Note: Due to lack of proper formatting tools, the code snippets have been manually indented and the format may break on smaller screens. I could put in screenshots over here but that defeats the purpose of code snippets. You can copy and paste them in your IDE for readability.

query AppQuery{  # Getting the logged in user
# viewer: User!
viewer{
id
name
}

# Getting a user by their ID
# user(id: ID!): User

user(id: "ANOTHERUSER1"){
id
name
}
# A query to get user by ID
# Returns ComplicatedResponse type - type with nesting
# complicatedUser(userId: ID!): ComplicatedResponse!
complicatedUser(userId: "ANOTHERUSER2"){
id # ID of ComplicatedResponseType
node{
id # ID of the User
name
}
}
# Getting a question by its ID
# question(id: ID!): Question
question(id: "QUESTION1"){
id
description(locale: "fr")
}
}

When you run the query, the store will have the following entries:

  1. A User type entry with the id of the logged-in user (say"VIEWERID")
  2. Another User type entry with id "ANOTHERUSER1"
  3. Another User type entry with id "ANOTHERUSER2"
  4. A Question type entry with id "QUESTION1"
  5. An entry for viewer which point to the entry mentioned in 1.
  6. An entry for user(id: “ANOTHERUSER1”) which points to the entry mentioned in 2.
  7. Let us assume that the id of the complicatedResponse type is “COMPLICATEDRESPONSE”. The store has an entry for complicatedUser(userId: “ANOTHERUSER2”) which points to the entry with id “COMPLICATEDRESPONSE”. The node field of this entry points to the entry mentioned in 3.
  8. An entry for question(id: “QUESTION1”) which points to the entry mentioned in 4.

Take a moment. Read it all again properly. Understand whatever I’ve said so far. This will be the base of all our discussion. Currently, the store can be mapped as below:

(ref Means reference)"VIEWERID" -> id: "VIEWERID", ..., type: "User",
"ANOTHERUSER1" -> id: "ANOTHERUSER1", ..., type: "User",
"ANOTHERUSER2" -> id: "ANOTHERUSER2", ..., type: "User",
"QUESTION1" -> id: "QUESTION1", ..., type: "Question"
"COMPLICATEDRESPONSE" -> id: "COMPLICATEDRESPONSE", type: "ComplicatedResposne", ..., (node -> ref "ANOTHERUSER2")viewer -> ref "VIEWERID"
user(id: “ANOTHERUSER1”) -> ref "ANOTHERUSER1"
complicatedUser(userId: “ANOTHERUSER2”) -> ref "COMPLICATEDRESPONSE"
question(id: “QUESTION1”) -> ref "QUESTION1"

We are all set. We have a schema and we have a store. The store has been populated with some entries. If you notice the schema, there are some other queries and mutations. We will use them later to illustrate appropriate concepts.

2.3 Three important interfaces

Relay docs cover three interfaces to deal with the store. For now, think of interfaces as a collection of methods and properties than an object provides. Interface decides how to interact with the corresponding object. Relay’s interfaces are listed as followings:

  1. RecordSourceSelectorProxy : Instance of the store that is provided to the updater functions. Used to pick records out of the store.
  2. RecordProxy : Instance of a given record. Used to traverse and mutate the record
  3. ConnectionHandler : It is not defined as an interface but exposed as a module that has exported methods. So we consider the interface exposed by the module.
import {ConnectionHandler} from "relay-runtime";

When I introduce the interfaces, I present the typing provided by the relay-runtime package as well as the interface definitions given in the docs. Package typings are more descriptive than the interfaces in the Relay docs. When discussing methods, I use method signatures from the Relay docs as well as the typings. You should refer to the signature most natural to you. In case a signature is not clear to you, you should refer to the interfaces defined in the corresponding introduction.

Note: As said, I use the typings that are provided by the relay-runtime package in some parts of the article. They are a bit messy and inconsistent with the docs and are subject to change in the future. I want you to keep that in mind while reading the article. You can always refer to the typings given in the relay docs

Typings use Primitive type to represent scalars. It is defined as string | number | boolean | null | undefined .

Note: Relay converts Enums to union of strings. For example enum MyEnum{ALPHA, BETA} becomes type MyEnum = "ALPHA" | "BETA" . So all the scalars are of Primitive type. Relay also defines a DataId type which is an alias for string. It is used to represent the id under Global Object Identification.

Also, as I said, I discuss the method signatures when I go over the method. So you don’t really need to read the whole interface. They are just mentioned for the sake of completeness. That being said, you might need to refer to the interfaces if the method signature refers to types defined in the interface definition. For example, consider the below interface:

interface MyInterface<T extends Record<string, any> = {}>{
myMethod: (arg: T) => void
}

To make sense of myMethod, you need to know about T which is defined with the interface.

3. RecordSourceSelectorProxy

Remember updater functions? I said that they get an argument for the store. The type of that argument is RecordSourceSelectorProxy . This interface lets you access the store in a controlled way and also lets Relay keep track of the changes you’ve made. This, in turn, helps with optimistic mutations where you update the store without waiting for the server’s response. In that case, if there is an error in mutation, Relay can rollback the store to the previous state. Using the RecordSourceSelectorProxy, you can create, get or delete the records in store. If you create or get the record, you get a RecordProxy type object. This interface helps you traverse and manipulate the record itself.

Now let us see the methods under RecordSourceSelectorProxy. As per the docs, the interface is given below:

interface RecordSourceSelectorProxy {
create(dataID: string, typeName: string): RecordProxy;
delete(dataID: string): void;
get(dataID: string): ?RecordProxy;
getRoot(): RecordProxy;
getRootField(fieldName: string): ?RecordProxy;
getPluralRootField(fieldName: string): ?Array<?RecordProxy>;
invalidateStore(): void;
}

If you look at the typings shipped with the relay-runtime package, you see the following:

interface RecordSourceProxy {
create(dataID: DataID, typeName: string): RecordProxy;
delete(dataID: DataID): void;
get<T = {}>(dataID: DataID): RecordProxy<T> | null | undefined;
getRoot(): RecordProxy;
}
interface RecordSourceSelectorProxy<T = {}> extends RecordSourceProxy {
getRootField<K extends keyof T>(fieldName: K): RecordProxy<NonNullable<T[K]>>;
getRootField(fieldName: string): RecordProxy | null; getPluralRootField(fieldName: string): Array<RecordProxy<T> | null> | null;
}

As said, most of the methods return a RecordProxy. So to understand the RecordSourceSelector properly, you need to know a bit of RecordProxy too. I have explained the appropriate bits wherever needed. If you feel that it isn’t enough, feel free to jump to the RecordProxy section for reference.

3.1 get

The prototype for this method is:

Docs: get(dataID: string): ?RecordProxyTypings: get<T = {}>(dataID: DataID): RecordProxy<T> | null | undefined;

This method takes a given id and fetches the corresponding record from the store. As per the typings, it can also take a generic type argument. You can pass your own type corresponding to the record fetched. Then you will have more strong typing for the record fields.

3.2 getRoot

The prototype for this method is:

Docs, Typings: getRoot(): RecordProxy

This method returns the root of the store. From here, you can access any record you want. Root effectively acts as the entry point into the store, as the name suggests. You traverse the store from root and get to the record you want. We shall see that in action later.

Now I am going to discuss a bit of RecordProxy. There is a section dedicated to RecordProxy later. But for now, I’ll just go over the things necessary to understand getRoot . RecordProxy essentially lets you access and set the fields of a record.

For example, if UserRecord is the RecordProxy for viewer, I can do:

  1. UserRecord.getValue("name"); to get the user’s name
  2. UserRecord.setValue(newNameValue ,"name"); to update the user’s name

But this works only if the corresponding field is a Scalar. Consider complicatedUser entry of complicatedResponse type. The node type is not a scalar but instead points to a User type object (with id VIEWERID ). Imagine complicatedRecord of ComplicatedResponse type. Here, complicatedRecord.getValue("node") will not work as the field is not a scalar.

In cases where the field is a GraphQL object type, you can use getLinkedRecord method. To access the name of the user in the node field, you can do the following:

const name = complicatedRecord
.getLinkedRecord("node")
.getValue("name")

Again, we will go over it properly when we discuss RecordProxy. But for now, this is enough. The root object can be used to access any data in the store. getRootmethod returns the RecordProxyfor the root. All the records are linked to the root. To get viewer entry from the store, we can do

const viewer = store
.getRoot()
.getLinkedRecord("viewer");

This returns the RecordProxy for the viewer. Now you can do viewer.getValue("name") to get the name of the viewer.

Consider the following mutation:

mutation UpdateUserMutation{# Update the user's name, given their ID
# updateUser(id: ID!, newName: String!): User!
updateUser(id: "ANOTHERUSER1", newName:"Random Name"){
id
name
}
}

After running the mutation to update user’s name, the following data is added to the store:

updateUser(id: “ANOTHERUSER1”, newName: “Random Name”) -> id: "ANOTHERUSER1", newName: "Random Name", type: "User"

You can retrieve this entry from the store by doing

const payload = store
.getRoot()
.getLinkedRecord('updateUser(id: “ANOTHERUSER1”, newName: “Random Name”)');

Now you have the payload. There are better ways to retrieve the above data though. We shall see them later, when we discuss appropriate methods. But for now, know that you can also do

const payload = store
.getRoot()
.getLinkedRecord("updateUser", {id: "ANOTHERUSER1", newName: "Random Name"});

Above is a neat way to bind variables to fields. In this example, we bound the arguments id: "ANOTHERUSER1" and newName: "Random Name" to updateUser.

3.3 getRootField

Relay typings provide two prototypes for this method — one with generics and the other without them. Here T refers to the type passed to the RecordSourceSelectorProxy interface.

Docs: getRootField(fieldName: string): ?RecordProxyTypings: 
1. getRootField<K extends keyof T>(fieldName: K): RecordProxy<NonNullable<T[K]>>;
2. getRootField(fieldName: string): RecordProxy | null;

This method also allows you to fetch records from the store. How is this different from the getRoot method? They differ in scope and use. While getRoot can get data from the entire store, getRootField cannot. It can only get the data corresponding to the concerned operation (the operation that invoked the updater function).

For example, consider the updateUser mutation:

mutation UpdateUserMutation{
updateUser(id: "ANOTHERUSER1", newName: "Random Name"){
id
name
}
}

This results in following GraphQL document:

updateUser(id: "ANOTHERUSER1", newName: "Random Name"){
id
name
}

getRootField can get data from this document only. But the good thing is that since this was made for small documents, you can directly get the records — without binding arguments to the field.

const payload = store.getRootField("updateUser");

You can use the RecordProxy to access the payload of the mutation.

const changedName = payload.getValue("name")

3.4 getPluralRootField

This method is almost the same as getRootField with a very subtle difference. The prototype for this method is:

Docs: getPluralRootField(fieldName: string): ?Array<?RecordProxy>Typings: getPluralRootField(fieldName: string): Array<RecordProxy<T> | null> | null;

As you can see, this method returns an array instead of a single RecordProxy. Like getRootField , this method is also limited to the GraphQL document generated by corresponding operation.

getRootField cannot get mutliple records. For such cases, we have the getPluralRootField method. So if the operation returns a collection of GraphQL objects instead of a single object, we use this method. What if the operation returned a Scalar or an array of Scalars? In that case, they won’t be stored in the store as the operation payload did not obey Global Object Identification. Moving on, Consider the generateQuestions mutation:

mutation GenerateQuestionsMutation{
# generate n random questions
# generateQuestions(n: Int!): [Question!]!
generateQuestions(n: 10){
id
description
}
}

This results in following GraphQL document:

generateQuestions(n: 10){
# Returns 10 randomly generated questions
id
description
}

If I try to use store.getRootField("generateUsers") , it won’t work as generateQuestions returns an array of Users . Instead, I need to do

const payload = store.getRootPluralField("generateQuestions");

The payload will contain the array of Questions (RecordProxy of type Question) . You can then access it like a normal array

const descriptions = payload.map(question => question.getValue("description"));

3.5 create

This method allows you to create an entity in the Relay store. The prototype for this method is

Docs, Typings: create(dataID: DataID, typeName: string): RecordProxy;

It returns the RecordProxy that can be used to manipulate the newly created node. To create a new User with a given ID, you can do

const newUser = store.create("NEWUSERID", "User");
newUser.setValue("name", "Lorem Ipsum")

3.6 delete

This method allows you to delete a record from the Relay store. The prototype for this method is

Docs, Typings: delete(dataID: DataID): void;

It deletes the entry with given ID from the store. Simple as that. Or is it? Just make sure that you delete the record from everywhere and that there are no references to it. If there is a reference to a record that does not exist, it is an error. Too confusing? My rule of thumb is to delete the record directly. If everything works fine, cool. If it doesn’t, you can look for references and delete them. Remember that you can always inspect the store using Relay DevTools to see what’s in the store and where.

3.7 invalidateStore

This method does not have any typing shipped as of now. I guess it is still a work in progress. As per the Relay docs, the prototype is

Docs: invalidateStore(): void

I haven’t worked with it much so take my words with a grain of salt. While firing a query again, you have an option to use the data from the store instead of getting it again. It can be done by setting fetchPolicy in QueryRenderer to "store-and-network”. In that case, if the QueryRenderer unmounts and then mounts again, it won’t fire the query again but uses the data from the store instead, provided that the data is still valid. In simple words, if you refetch a query whose data is already in the store, data is retrieved from the store and not the network.

If at some point in the program flow you want to programmtically change this behaviour, you can use this method. When invalidateStore is called, all the queries that were fired before invalidation are marked stale. Relay docs mention that state of a query is checked by

environment.check(query) === "stale" 

Since all queries are now stale, they will be refetched when checked. This means that QueryRenderer will not use any data from the store but will refetch it instead.

3.8 Summary

Let us quickly go over whatever we have just seen. The RecordSourceSelectorProxy lets you access the store in a controlled way. It provides the following methods:

  1. get : Gets a record from the store from its ID.
  2. getRoot : Gets the root of the store. Can be used to access any record.
  3. getRootField : Gets a singular field from a given GraphQL document. Can get the field with just the name (without specifying variables of the field).
  4. getPluralRootField : Like getRootField but gets a plural field (a field that returns an array) from the given GraphQL document.
  5. create : Creates a new record of given type and ID.
  6. delete : Deletes a record with a given ID.
  7. invalidateStore : Marks the data of store as stale.

4. RecordProxy

Now, this is where things get interesting. RecordSourceSelectorProxy , as the name suggests, gets you the source of the record. For example,

const root = store.getRoot();
const viewerRecord = store.getRoot().getLinkedRecord("viewer");

Now Root is a RecordProxy . RecordProxy interface lets you traverse and manipulate records. It has a lot of methods and can be a bit long to go through. Ready to see the interface?

Note: Like said before, you do not need to read these interfaces here. They are just mentioned for completeness. You should analyse how types are used though.

As per the Relay docs, the interface is given below:

interface RecordProxy {
copyFieldsFrom(sourceRecord: RecordProxy): void;
getDataID(): string; getLinkedRecord(name: string, arguments?: ?Object): ?RecordProxy; getLinkedRecords(name: string, arguments?: ?Object): ?Array<?RecordProxy>; getOrCreateLinkedRecord(
name: string,
typeName: string,
arguments?: ?Object,
): RecordProxy;
getType(): string; getValue(name: string, arguments?: ?Object): mixed; setLinkedRecord(
record: RecordProxy,
name: string,
arguments?: ?Object,
): RecordProxy;
setLinkedRecords(
records: Array<?RecordProxy>,
name: string,
arguments?: ?Object,
): RecordProxy;
setValue(value: mixed, name: string, arguments?: ?Object): RecordProxy; invalidateRecord(): void;
}

As per the typings, the interface is given as:

interface RecordProxy<T = {}> {copyFieldsFrom(source: RecordProxy): void;getDataID(): DataID;// If a parent type is provided, provide the child type
getLinkedRecord
<K extends keyof T>(name: K, args?: Variables | null): RecordProxy<NonNullable<T[K]>>;
// If a hint is provided, the return value is guaranteed to be the hint type
getLinkedRecord
<H = never>(
name: string,
args?: Variables | null,
): [H] extends [never] ? RecordProxy | null : RecordProxy<H>;
getLinkedRecords<K extends keyof T>(
name: K,
args?: Variables | null,
): Array<RecordProxy<Unarray<NonNullable<T[K]>>>>;
getLinkedRecords<H = never>(
name: string,
args?: Variables | null,
): [H] extends [never]
? RecordProxy[] | null
: NonNullable<H> extends Array<infer U>
? Array<RecordProxy<U>> | (H extends null ? null : never)
: never;
getOrCreateLinkedRecord(name: string, typeName: string, args?: Variables | null): RecordProxy<T>;getType(): string;getValue<K extends keyof T>(name: K, args?: Variables | null): T[K];getValue(name: string, args?: Variables | null): Primitive | Primitive[];setLinkedRecord<K extends keyof T>(
record: RecordProxy<T[K]> | null,
name: K,
args?: Variables | null,
): RecordProxy<T>;
setLinkedRecord(record: RecordProxy | null, name: string, args?: Variables | null): RecordProxy;setLinkedRecords<K extends keyof T>(
records: Array<RecordProxy<Unarray<T[K]>> | null> | null | undefined,
name: K,
args?: Variables | null,
): RecordProxy<T>;
setLinkedRecords(
records: Array<RecordProxy | null> | null | undefined,
name: string,
args?: Variables | null,
): RecordProxy<T>;
setValue<K extends keyof T>(value: T[K], name: K, args?: Variables | null): RecordProxy<T>;setValue(value: Primitive | Primitive[], name: string, args?: Variables | null): RecordProxy;
}

Again, this is way too much to take in. But you do not have to read the interfaces. You can look at the methods as we discuss them.

4.1 getDataID

The prototype for this method is

Docs: getDataID(): string;
Typings: getDataID(): DataID;

Relay has defined type DataID to be string

export type DataID = string;

This method simply allows you to get the data id of a given record which Relay uses to identify the record. It is usually same as the id attribute of the record. But that may not always be the case, as we will see under the getOrCreateLinkedRecord and copyFieldsFrom methods.

4.2 getType

The prototype for this method is

Docs, Typings: getType(): string;

This method allows you to see the type of record. Again, nothing much here.

const viewerType = store.getRoot().getLinkedRecord("viewer").getType(); 

When you log the value, you the following:

console.log(viewerType);> "User"

4.3 getValue

Like getRootField method on RecordSourceSelectorProxy , this method also exposes two prototypes:

Docs: getValue(name: string, arguments?: ?Object): mixedTypings:
1. getValue<K extends keyof T>(name: K, args?: Variables | null): T[K];
2. getValue(name: string, args?: Variables | null): Primitive | Primitive[];

Again, the generics are for type safety. Now, getValue is used to access fields that represent Scalars or their array. That is, it cannot be used if the field itself is a GraphQL object. For that, we have getLinkedRecord method.

To recap, Relay defines primitive types as

type Primitive = string | number | boolean | null | undefined;

getValue should be used when the field returns any of the above type or an array of the above types. Also, enums are converted to string type. So, all Scalars ultimately resolve to a primitive type.

For example, to get name of the viewer , you can do

const name = store
.getRoot()?
.getLinkedRecord("viewer")?
.getValue("name")

If you do not understand the ? (optional chaining) operator, it is equivalent to

object?.property === object && object.property

Remember that we fetched the question with id "QUESTION1"? Let us get it’s description.

const question = store
.getRoot()
.getLinkedRecord
("question", "id:"QUESTION1"})
const desc = question?.getValue(`description(locale: "fr")`)

Too clumsy, isn’t it? Luckily, getValue supports a second argument for variables.

const desc = question?.getValue("description", {locale: "fr"})

4.4 getLinkedRecord

This method also exposes two prototypes. Like getValue , this method also allows passing variables in the second argument

Docs: getLinkedRecord(name: string, arguments?: ?Object): ?RecordProxyTypings: 
1. // If a parent type is provided, provide the child type
getLinkedRecord
<K extends keyof T>(name: K, args?: Variables | null): RecordProxy<NonNullable<T[K]>>;
2. // If a hint is provided, the return value is guaranteed to be the hint type
getLinkedRecord
<H = never>(
name: string,
args?: Variables | null,
): [H] extends [never] ? RecordProxy | null : RecordProxy<H>;

We know that getValue works only when the field to be fetched is a primitive or an array of primitive types. But what if the field is a GraphQL object type? In that case, you can use getLinkedRecord .

Let us assume that complicatedUser is the RecordProxy for the complicatedUser query’s response. To get the name of the corresponding user, you can do

const complicatedUser = store
.getRoot()
.getLinkedRecord("complicatedUser", {userId: "ANOTHERUSER2"})
const name = complicatedUser?.getLinkedRecord("node")?.getValue("name")

4.5 getLinkedRecords

This method has the following prototype:

Docs: getLinkedRecords(name: string, arguments?: ?Object): ?Array<?RecordProxy>Typings: 
1. getLinkedRecords<K extends keyof T>(
name: K,
args?: Variables | null,
): Array<RecordProxy<Unarray<NonNullable<T[K]>>>>;
2. getLinkedRecords<H = never>(
name: string,
args?: Variables | null,
): [H] extends [never]
? RecordProxy[] | null
: NonNullable<H> extends Array<infer U>
? Array<RecordProxy<U>> | (H extends null ? null : never)
: never;

We have seen that to get a GraphQL object type, we use the getLinkedRecord method. But if the field returns an array of GraphQL objects, you cannot use this method. Instead, you use getLinkedRecords which returns an array of RecordProxy.

Imagine the following mutation:

mutation generateQuestions{
generateQuestions(n: 10){
id
description
}
}

After firing the mutation, you can get the questions by doing:

const users = store
.getRoot()
.getLinkedRecords("generateQuestions", {n: 10});

Alternatively, you could do:

const users = store.getPluralRootField("generateUsers")

Now, you can iterate over users to get the RecordProxy for the individual user.

4.6 getOrCreateLinkedRecord

This method has the following prototype:

Docs: getOrCreateLinkedRecord(name: string, typeName: string, arguments?: ?Object)Typings: getOrCreateLinkedRecord(name: string, typeName: string, args?: Variables | null): RecordProxy<T>;

This method will try to retrieve the specified record. If the record does not exist, it will create one. Consider the action:

const id = store
.getRoot()
.getOrCreateLinkedRecord("gksj", "User")
.getDataID()

The above snippet gets a record of User type which is linked to the store root by gksj field. If such a record does not exist, it is made and attached to the gksj field of the store root. You finally retrieve the record’s id.

As you might’ve guessed, Relay creates an empty record. Irrespective of the schema, all the fields of the newly created users will give undefined when accessed, including the id field. Relay will assign a data id to the record which can be found using getDataID method. You should use this id to retrieve the record in future. This is an example where DataId and id are not same.

If you need to bind arguments to the field, the method accepts a third argument which is an object corresponding to the field variables.

4.7 setValue

Relay exposes the below prototypes for this method:

Docs: setValue(value: mixed, name: string, arguments?: ?Object): RecordProxyTypings: 
1.setValue<K extends keyof T>(value: T[K], name: K, args?: Variables | null): RecordProxy<T>;
2. setValue(value: Primitive | Primitive[], name: string, args?: Variables | null): RecordProxy;

It is used to set a Scalar value (or an array of Scalar values) corresponding to a record field. Like getValue , it also accepts an argument for variables.

Imagine the following example:

const updatedViewer = store
.getRoot()
.getLinkedRecord("viewer")
.setValue("Yash", "name")

The snippet is pretty much self-explanatory. You get the root, traverse it to get the viewer and then update the name field of the viewer record. This method returns the mutated record.

4.8 copyFieldsFrom

This method has the following prototype:

Docs, Typings: copyFieldsFrom(source: RecordProxy): void;

This method will update the selected RecordProxy with a given RecordProxy . For example

const user = store
.getRoot()
.getOrCreateLinkedRecord("gksj", "User")
user.copyFieldsFrom(store.getRoot().getLinkedRecord("viewer"))

You retrieve a user which is linked to store root bygksj field. Then you update it with viewer . As a result, the all the field of user are set to the fields of viewer.

The updated record

The id field of the record is the same as the id of viewer. But the data id (__id) which relay uses to identify the record is different. To retrieve this record, you need to do:

store.get("client:root:gksj")

As long as you do not use id in your code, multiple records with the same id fields do not cause a problem as Relay internally uses different DataId to identify them. This is another example where id and DataId are different.

4.9 setLinkedRecord

Relay exposes the below prototypes for this method:

Docs: setLinkedRecord(record: RecordProxy, name: string, arguments?: ?Object)Typings: 
1. setLinkedRecord<K extends keyof T>(
record: RecordProxy<T[K]> | null,
name: K,
args
?: Variables | null,
): RecordProxy<T>;
2. setLinkedRecord(record: RecordProxy | null, name: string, args?: Variables | null): RecordProxy;

It is used to set a linked record corresponding to a record field. Like getValue and setValue, it also accepts an argument for variables that can be bound to the field while accessing it.

Imagine the following example:

const newUser = store.create("RANDOMDATAID", "USER");const complicatedUser = store
.getRoot()
.getLinkedRecord("complicatedUser", {userId: "ANOTHERUSER2"});
complicatedUser.setLinkedRecord(newUser, "node")

This changes the node of the complicatedUser type record to the newly created user. Note that the ANOTHERUSER2 is the id of the User pointed by the node field of the complicatedUser(userId: "ANOTHERUSER2") record. It is not the id of the complicatedResponse type object returned by the complicatedUser query.

4.10 setLinkedRecords

Relay exposes the below prototypes for this method:

Docs: setLinkedRecords(records: Array<RecordProxy>, name: string, variables?: ?Object)Typings: 
1. setLinkedRecords<K extends keyof T>(
records: Array<RecordProxy<Unarray<T[K]>> | null> | null | undefined,
name: K,
args?: Variables | null): RecordProxy<T>;
2. setLinkedRecords(records: Array<RecordProxy | null> | null | undefined,
name: string,
args?: Variables | null): RecordProxy<T>;

It is used to set a list of linked records corresponding to a record field. Consider the users query. It returns a paginated result.

query UsersQuery{
users(first: 10){
edges{
node{
id
name
}
}
pageInfo{
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}

Now, I might want to remove User with a given name from the result of this query. For that:

const edges = store
.getRootField("users")?
.getLinkedRecords("edges")
edges.filter(edge => edge.getLinkedRecord("node")?.getValue("name") !== "NAME TO BE REMOVED")store.getRootField("users").setLinkedRecords(edges, "edges");

Like setLinkedRecord, the third argument of this method is to provide variables for the field selected. There is a better way to do the above, as we shall see under the next interface.

4.11 invalidateRecord

Like invalidateStore , relay runtime does not ship any typings for this method. The prototype for this method is

invalidateRecord(): void

This method is similar to invalidateStore. It is used to invalidate a given record. Any query that references this record will be marked stale. When that query is checked using environment.check(query) === ‘stale’ , it will need a refetch. Any other query that does not reference this record will be valid and won’t be refetched.

4.12 Summary

This section was about manipulating RecordProxy. As you might’ve observed, the previous section and this section are complementary. After covering these sections, you can confidently tackle the store and deal with the data in it. The methods covered in this section were:

  1. getDataId : get the data ID (used to identify a record in the store) for a given RecordProxy
  2. getType : get the GraphQL type of a given RecordProxy
  3. getValue : Access Scalar value(s) corresponding to a field of a given RecordProxy
  4. getLinkedRecord : Access a non Scalar field of a RecordProxy
  5. getLinkedRecords : Access a field of a given recordProxy that corresponds to a collection of non Scalars
  6. setValue : Set Scalar value(s) corresponding to a field of a given RecordProxy
  7. setLinkedRecord : Set a non Scalar corresponding to a field of a RecordProxy
  8. setLinkedRecord : Set a collection of non Scalars corresponding to a field of a RecordProxy
  9. getOrCreateLinkedRecord : Try to access a non Scalar field on a RecordProxy . If the field does not exist, creates an entry for it.
  10. copyFieldsFrom : Updates a given RecordProxy by replacing the fields with that of another givenRecordProxy.
  11. invalidateRecord : Marks a record invalid so queries using it need to be refetched if their data is to be used again.

5. ConnectionHandler

This interface mainly deals with manipulating pagination connections. Relay runtime provides a module corresponding to it

import {ConnectionHandler} from "relay-runtime";

The docs provide the following interface :

interface ConnectionHandler{
getConnection(
record: RecordProxy,
key: string,
filters?: ?Object,
): ?RecordProxy,
createEdge(
store: RecordSourceProxy,
connection: RecordProxy,
node: RecordProxy,
edgeType: string,
): RecordProxy,
insertEdgeBefore(
connection: RecordProxy,
newEdge: RecordProxy,
cursor?: ?string,
): void,
insertEdgeAfter(
connection: RecordProxy,
newEdge: RecordProxy,
cursor?: ?string,
): void,
deleteNode(connection: RecordProxy, nodeID: string): void
}

The module exports following methods:

export function createEdge(
store: RecordSourceProxy,
record: RecordProxy,
node: RecordProxy,
edgeType: string,
): RecordProxy;
export function deleteNode(record: RecordProxy, nodeID: DataID): void;export function getConnection(
record: ReadOnlyRecordProxy,
key: string,
filters?: Variables | null,
): RecordProxy | null | undefined;
export function insertEdgeAfter(record: RecordProxy, newEdge: RecordProxy, cursor?: string | null): void;export function insertEdgeBefore(record: RecordProxy, newEdge: RecordProxy, cursor?: string | null): void;

As you can see, the typings are almost the same. So I’ll refer to the typings from the docs directly.

5.1 getConnection

The method has the following prototype

getConnection(record: RecordProxy, key: string, filters?: ?Object)

Consider the below query:

fragment UserList on Query{
users(first: 10, orderBy: "name") @connection(
key: "UserList_users"
)
{
edges{
node{
id
name
}
}
pageInfo{
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
}
query UserListQuery{
...UserList_users
}

To access the records, you can do

const users = store
.getRoot().getLinkedRecord("users", {first: 10, orderBy: "name"})

But ConnectionHandler provides a better way via getConnection.

const users = ConnectionHandler.getConnection(// parent record
store.getRoot(),
// connection key
'UserList_users',
// 'filters' :identify the connection
{orderby: 'name'}
);

This accesses the connections in a way natural to Relay — with a key and filters. Now that you have users, you can do

const edges = users.getLinkedRecords("edges")

5.2 deleteNode

Prototype for this method is:

deleteNode(connection: RecordProxy, nodeID: string): void

It takes a given connection and removes the node with given id from it. Consider the users connection from getConnection example. To remove a given user with a given id, you can do:

ConnectionHandler.deleteNode(users, 'IDTOBEDELETED')

5.3 createEdge

Prototype for this method is:

createEdge(store: RecordSourceProxy, connection: RecordProxy, node: RecordProxy, edgeType: string)

This method creates an edge of a given type on a given connection. Consider the users connection from getConnection example. To create an edge for that connection,

//Create a user type object
const user = store.create("NEWUSERID", "USER");
//Populate the fields
user
.setValue("RANDOMNAME", "name");
//Create an edge that can be inserted into the users connection
const edge = ConnectionHandler.createEdge(store, users, user, 'UserEdge');

The method takes a RecordSourceProxy to access the store. It also takes a connection where the edge is supposed to be inserted, a node which is to be contained by the edge and the GraphQL type of the edge.

Note that creating an edge does not insert the edge in the connection. It just gets you an edge that is ready for insertion in connection. To insert an edge, we have the next two methods

5.4 insertEdgeBefore

Prototype for this method is:

insertEdgeBefore(connection: RecordProxy, newEdge: RecordProxy, cursor?: string)

This method inserts an edge of a given type on a given connection. Consider the users connection from getConnection example and the edge created in the createEdge example. This method accepts a third argument for a cursor. The edge is inserted before that cursor. If the cursor argument is not specified, it inserts the edge at the beginning of the connection.

// Insert at the beginning
ConnectionHandler
.insertEdgeBefore(users, edge)
// Insert after a cursor
ConnectionHandler
.insertEdgeAfter(users, edge, 'MyCursor')

5.5 insertEdgeAfter

Prototype for this method is:

insertEdgeAfter(connection: RecordProxy, newEdge: RecordProxy, cursor?: string)

This method also inserts an edge of a given type on a given connection. Consider the users connection from getConnection example and the edge created in the createEdge example. This method accepts a third argument for a cursor. The edge is inserted after that cursor. If the cursor argument is not specified, it inserts the edge at the end of the connection.

// Insert at the end
ConnectionHandler
.insertEdgeAfter(users, edge)
// Insert after a cursor
ConnectionHandler
.insertEdgeAfter(users, edge, 'MyCursor')

5.6 Summary

This section dealt with manipulating connections. We saw the following methods:

  1. getConnection : get a connection with given parameters from the store
  2. deleteNode : remove a node from a given connection
  3. createEdge : create an edge that can be inserted on a given connection
  4. insertEdgeBefore : inserts an edge at the beginning or before a given cursor
  5. insertEdgeAfter : inserts an edge at the end or after a given cursor

The module also exports a few other things but they are most probably for Relay’s internal usage.

Wrapping up

While this article might have seemed endless, it has an end. Is that a good thing? Is that a bad thing? I leave that up to you. This article tries to cover most of the stuff. But there are some things that you learn as you use Relay and it is neither feasible nor wise to include them here. The internal working of the store may change over time but the methods should remain the same — more or less. I will try to keep this article updated until Relay docs improve. I also plan to add a common mistakes section later on.

Did this article help you out? Did it miss something? Could it use a bit of trimming? How could it be better? Let me know in the comments below : )

--

--