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:
includeIDs
to store the five artworks in the list, so users see a consistent list of artworks whenever they log in and load the pageexcludeIDs
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).
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:
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:
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.)
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
- Relay is great because it colocates a component with its data requirements.
- The Relay store allows us to access and modify data that we are using on the client.
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.