A deep dive into the Relay store
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.
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:
- ES6 — modules 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
- Knowledge of GraphQL
- Basic experience with Relay
- [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:
- Start with the section you want to read.
- If at some point, that section uses contents from another section without explaining it properly, refer to the other section.
- 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
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.
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.
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:
- A
User
type entry with the id of the logged-in user (say"VIEWERID"
) - Another
User
type entry with id"ANOTHERUSER1"
- Another
User
type entry with id"ANOTHERUSER2"
- A
Question
type entry with id"QUESTION1"
- An entry for
viewer
which point to the entry mentioned in 1. - An entry for
user(id: “ANOTHERUSER1”)
which points to the entry mentioned in 2. - Let us assume that the id of the
complicatedResponse
type is“COMPLICATEDRESPONSE”
. The store has an entry forcomplicatedUser(userId: “ANOTHERUSER2”)
which points to the entry with id“COMPLICATEDRESPONSE”
. Thenode
field of this entry points to the entry mentioned in 3. - 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:
RecordSourceSelectorProxy
: Instance of the store that is provided to the updater functions. Used to pick records out of the store.RecordProxy
: Instance of a given record. Used to traverse and mutate the recordConnectionHandler
: 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:
UserRecord.getValue("name");
to get the user’s nameUserRecord.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. getRoot
method returns the RecordProxy
for 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:
get
: Gets a record from the store from its ID.getRoot
: Gets the root of the store. Can be used to access any record.getRootField
: Gets a singular field from a given GraphQL document. Can get the field with just the name (without specifying variables of the field).getPluralRootField
: LikegetRootField
but gets a plural field (a field that returns an array) from the given GraphQL document.create
: Creates a new record of given type and ID.delete
: Deletes a record with a given ID.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 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:
getDataId
: get the data ID (used to identify a record in the store) for a givenRecordProxy
getType
: get the GraphQL type of a givenRecordProxy
getValue
: AccessScalar
value(s) corresponding to a field of a givenRecordProxy
getLinkedRecord
: Access a nonScalar
field of aRecordProxy
getLinkedRecords
: Access a field of a givenrecordProxy
that corresponds to a collection of nonScalars
setValue
: SetScalar
value(s) corresponding to a field of a given RecordProxy
setLinkedRecord
: Set a nonScalar
corresponding to a field of aRecordProxy
setLinkedRecord
: Set a collection of nonScalars
corresponding to a field of aRecordProxy
getOrCreateLinkedRecord
: Try to access a nonScalar
field on aRecordProxy
. If the field does not exist, creates an entry for it.copyFieldsFrom
: Updates a givenRecordProxy
by replacing the fields with that of another givenRecordProxy
.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:
getConnection
: get a connection with given parameters from the storedeleteNode
: remove a node from a given connectioncreateEdge
: create an edge that can be inserted on a given connectioninsertEdgeBefore
: inserts an edge at the beginning or before a given cursorinsertEdgeAfter
: 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 : )