Accessing the Relay Store Without a Mutation

I recently encountered a problem where client-side data (returned from a Relay query) became out of sync after a user interaction. How can we make sure our data is consistent while maintaining a single source of truth? This post explores why a developer might want to update client-side data locally, the basics of Relay and […]

I recently encountered a problem where client-side data (returned from a Relay query) became out of sync after a
user interaction. How can we make sure our data is consistent while maintaining a single source of truth? This post
explores why a developer might want to update client-side data locally, the basics of Relay and its store, and how
to delete records in the store when you’re not using a mutation.

Relay x Artsy x Me

Relay is a GraphQL client library maintained by Facebook engineers and enables rapid client-side data
fetching in React applications. Artsy’s adoption of Relay coincided with our move
toward using React Native for our mobile work around 2016. I joined Artsy as an engineer in November of 2020 (after
transitioning to engineering from a non-technical role at the
company
.) When I joined, I was about a year into React development
and completely new to Relay.

I work on the Partner Experience (PX) team at Artsy. We build and maintain software used by our gallery and auction
house partners to sell artwork on Artsy. Although Relay is not new to Artsy, it’s relatively new to our team’s main
repository, Volt. (Volt is Artsy’s CMS used by gallery partners to manage their presences on the platform.) A topic
for another blog post, but Volt’s structure is worth noting here: Volt is a collection of mini React apps injected
into HAML views—our way of incrementally converting the codebase to our new stack.

Relay’s biggest advantage in my eyes is how it tightly couples the client view and API call (in our case, to the
GraphQL layer of our stack, which we call Metaphysics.) In addition to performance and other benefits, colocating a
component with its data requirements creates a pretty seamless developer experience.

Building an Artwork Checklist

On the PX team, we recently launched a checklist feature aimed at empowering our gallery partners to be more
self-sufficient and find the greatest success possible on Artsy. The checklist prompts galleries to add specific
metadata to artworks that we know (because of our awesome data team) will make the work more likely to sell. The
new feature gathers a list of five high-priority artworks (meaning they are published, for-sale, and by a
top-selling artist) that are missing key pieces of metadata. The checklist prompts users to add the missing
metadata. Users also have the ability to click a button to “snooze” works, which removes them from the list for the
day.


The feature makes use of Redis, a key-value store used for in-memory cache, to store two lists:

  1. includeIDs to store the five artworks in the list, so users see a consistent list of artworks whenever they
    log in and load the page
  2. excludeIDs or “snoozed” IDs which Redis will store for 24 hours and ensure the user does not see

When a user presses the “snooze” button, the ID for the artwork is added to the snoozed list in Redis. The list of
includeIDs and the list of excludeIDs are passed down from Rails controllers to our HAML views and then passed
as props into our React HomePageChecklist app. In our Checklist component, we use both the includeIDs and the
excludeIDs as arguments passed to our Relay query to determine what is returned from Metaphysics (Artsy’s GraphQL
layer).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fragment ArtworksMissingMetadata_partner on Partner
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 5 }
    after: { type: "String" }
    includeIDs: { type: "[String!]" }
    excludeIDs: { type: "[String!]" }
  ) {
    id
    artworksConnection(
      first: $first
      after: $after
      includeIDs: $includeIDs
      excludeIDs: $excludeIDs
    ) @connection(key: "ArtworksMissingMetadata_partner_artworksConnection", filters: []) {
      edges {
        node {
          ...ArtworksMissingMetadataItem_artwork
        }
      }
    }
  }

Problem: How to Change the Data Displayed When a User Interacts with the Page

The problem we were running into occurs when the user presses “snooze” on an item. We successfully update Redis
with the new snoozed item, but the UI still renders the item on the page. (This is because the response from Relay
becomes stale.) If the user refreshes the page, the list is correct: The up-to-date Redis excludeIDS list will be
passed into our component and used in the Relay query. But without refreshing the page, we need to make sure that
the list in the UI updates when the user snoozes an item.

The initial fix was to use a local state variable to keep track of which items were snoozed. We defined the following variable in the parent
React component that renders the list:

1
const [localSnoozedItems, setLocalSnoozedItems] = useState([])

We passed localSnoozedItems and setLocalSnoozedItems down to each of the children items. When the “snooze”
button was pressed on an item, the localSnoozedItems in the parent was updated with the complete list of snoozed
items. The parent then controls which items get rendered. We used the localSnoozedItems list to filter the connection
returned from our Relay query (which remember, is already filtered based on our Redis excludeIDs from Redis.)

This worked, but it definitely did not feel great to have two sources of truth for snoozing: The Redis key and the
local state variable.

Solution: Deleting a Record From the Relay Store

Cue the RelayModernStore! I learned that Relay keeps track of the GraphQL
data returned by each query in a store on the client. Each record in the store has a unique ID, and the store can be
changed, added to, and deleted from. There are a couple of helpful blog posts (like
this and
this) that explain the store and how to
interact with it.

In most of the Relay documentation, blog posts, and Artsy’s uses cases, the store is accessed through an updater
function via mutations. Updater functions
that return the store in the first argument can optionally be added to Relay mutations. Inside that function, you can access
the store to modify the records you need.

Here’s an example:

1
2
3
4
5
6
7
8
9
10
commitMutation(defaultEnvironment, {
  mutation: graphql`
    mutation SomeMutation {
      ...
    }
  `,
  updater: (store) => {
    // Do something with the store
  },
})

In my use case, I was not using a Relay mutation because I did not need to modify anything on the server. Since
Redis is keeping track of our excludeIDs for us, any round trip to the server will be up-to-date. We just need to
modify our local data store.

Relay provides a separate API method to make local updates to the Relay
store: commitLocalUpdate. commitLocalUpdate takes two arguments: the first is the Relay environment, which you
can easily access from the parent Relay fragment or refetch container. The second is an updater callback function
that returns the store in the first argument. We now have access to the store!

Deleting a Connection Node with ConnectionHandler

My main hurdle during this journey was finding an appropriate way to hook into the store for our specific use case—when we do
not require an update to server data.

But to close us out: Let’s finish the job and delete the item from the connection in the store.

When an item is snoozed, we call commitLocalUpdate, pass in the Relay environment, and then pass in the updater
function. Once we have access to the store, our goal is to delete this particular item from the
artworksConnection, which is the GraphQL object returned by our original Relay query.

Because we are dealing with connections, we want to use the ConnectionHandler
API
provided by Relay. ConnectionHandler.getConnection takes in the
connection’s parent record (which we can find using the GraphQL ID added as a field on our query for the
connection) as the first argument and the connection key which can be provided through Relay’s @connection
directive
.

Once we have the connection, we will use ConnectionHandler.deleteNode which takes the connection as the first
argument and the id to be deleted, which we can also easily access using the GraphQL ID added as a field to the
query for the item.

Bonus: Because commitLocalUpdate works anywhere in Relay land, we got to perform this deletion exactly where the
“snooze” action is happening: in the child item component. (In our previous solution, we had to manage the state of
the children from their parent component, which wasn’t as intuitive.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { commitLocalUpdate } from "relay-runtime"

commitLocalUpdate(relay.environment, (store) => {
  const parentRecord = store.get(parentID)

  if (parentRecord) {
    const artworksConnection = ConnectionHandler.getConnection(
      parentRecord,
      "ArtworksMissingMetadata_partner_artworksConnection"
    )
    if (artworksConnection) {
      ConnectionHandler.deleteNode(artworksConnection, id)
    }
  }
})

Key Takeaways

  1. Relay is great because it colocates a component with its data requirements.
  2. The Relay store allows us to access and modify data that we are using on the client.
  3. commitLocalUpdate provides us access to the store if we just need to modify local data and aren’t using a
    mutation to update server-side data.

Source: Artsy