In Relay, a news article's like button and add-comment form both use useMutation; how do the like count and the comment thread update, instantly and after the server replies?
answer
- records keyed by id
- select what changed in the payload
- optimistic data is rolled back
- connection ids passed as a variable
basics
~20 sRelay merges payload objects into stored records by id, so a like payload selecting likeCount updates every reader; an optimisticResponse shows it instantly and is rolled back on reply. A new comment is inserted with @prependEdge and the thread's connection id.
solid answer
~40 s`useMutation` returns `[commit, isInFlight]`. For the like, the mutation selects `article { id likeCount viewerHasLiked }`: when the response arrives, Relay finds the record with that `id` and merges the fields, and every component whose fragment reads them re-renders, with no updater. Passing `optimisticResponse` writes the expected payload immediately; when the request succeeds or fails, Relay rolls it back and, on success, writes the real response (add `@raw_response_type` to type it). The new comment is different: it is a new edge, and Relay cannot guess which lists should contain it. Select the edge in the payload with `@prependEdge(connections: $connections)` and pass the thread's connection id, read from `__id` or `ConnectionHandler.getConnectionID(articleId, "CommentThread_comments")`. An `updater` handles anything the directives cannot, and runs after the server data is written.
code
tsx · 27 linesimport { graphql, useFragment, useMutation } from 'react-relay';
import type { LikeButton_article$key } from './__generated__/LikeButton_article.graphql';
import type { LikeButtonLikeMutation } from './__generated__/LikeButtonLikeMutation.graphql';
export function LikeButton({ article }: { article: LikeButton_article$key }) {
const data = useFragment(
graphql`fragment LikeButton_article on Article { id likeCount viewerHasLiked }`,
article,
);
const [commit, isInFlight] = useMutation<LikeButtonLikeMutation>(graphql`
mutation LikeButtonLikeMutation($input: LikeArticleInput!) @raw_response_type {
likeArticle(input: $input) { article { id likeCount viewerHasLiked } }
}
`);
const onClick = () =>
commit({
variables: { input: { articleId: data.id } },
optimisticResponse: {
likeArticle: { article: { id: data.id, viewerHasLiked: true, likeCount: data.likeCount + 1 } },
},
});
return (
<button disabled={isInFlight || data.viewerHasLiked} onClick={onClick}>
Like ({data.likeCount})
</button>
);
}go deeper
Recall that a mutation should select the changed fields with the record's id, and that Relay merges them into the store so every reader updates.
Explain optimisticResponse and its rollback, why a new comment needs @prependEdge or @appendEdge with connection ids, and where __id and getConnectionID come from.
Show production judgment: the order of optimistic writes, rollback and updater, the pitfalls of store-dependent optimistic values, invalidation for rippling changes, and why ids must be globally unique.
Set a team convention: payloads return changed records, directives before updaters, and a schema with global ids, so mutation code stays declarative across every team touching the article page.
## How Relay applies a mutation payload The Relay store is **normalized**: every object with an `id` is one record, keyed by that id, and every fragment reading that record sees the same values. A mutation response goes through the same normalization as a query response. So the first rule of Relay mutations is: **select, in the payload, the fields that changed**. The compiler adds `id` to types that have one, and when the response arrives, Relay finds the existing record with that id and merges the new fields in. Any component whose selection includes those fields re-renders; components that did not select them do not. This depends on ids identifying records uniquely across types. Relay's default `getDataID` uses the object's `id` value alone, which is why Relay expects **globally unique ids**, as in the `Node` convention. ## The like: select what changed The like button's mutation, `LikeButtonLikeMutation`, returns the article with `id`, `likeCount` and `viewerHasLiked`. After the response, the like button, the article header's count and any other reader of those fields update together, without an `updater` and without refetching a query. ## Showing it instantly: optimisticResponse Waiting a round trip for a heart icon to fill feels broken, so pass `optimisticResponse`, an object shaped like the mutation's response: - It is written to the store at once; readers re-render immediately. - When the request completes, successfully or not, the optimistic write is **rolled back**. On success the real payload is then written; on failure `onError` runs and the UI returns to the stored values. - For TypeScript to type it, add `@raw_response_type` to the mutation. The docs add two cautions. An optimistic response includes the contents of spread fragments, so readers selecting more fields can see partial data during the optimistic phase. And when the optimistic value is computed from the store (a count plus one) and several such mutations can be in flight, rolling one back leaves the others applied; an `optimisticUpdater`, which edits the store through a proxy, is the documented alternative. ## The new comment: connections A new comment is a **new edge in a connection**, and Relay does not know which lists (newest first, top comments, a reply thread) should contain it. You tell it with declarative directives on the payload field: | Directive | Placed on | Effect | |---|---|---| | `@prependEdge(connections: $connections)` | an edge field | inserts the edge at the start of each listed connection | | `@appendEdge(connections: $connections)` | an edge field | inserts it at the end | | `@prependNode` / `@appendNode` (with `edgeTypeName`) | a node field | wraps the node in a new edge, then inserts it | | `@deleteEdge(connections: $connections)` | an id field | removes that node's edges from the listed connections | | `@deleteRecord` | an id field | deletes the record from the store | `$connections` is a `[ID!]!` variable of **connection ids**. Get one by selecting `__id` on the connection in the thread's fragment, or with `ConnectionHandler.getConnectionID(articleId, "CommentThread_comments", filters)`. A connection with filter arguments needs one id per filter combination you want updated. ## Order of execution 1. `optimisticResponse` is written to the store. 2. `optimisticUpdater` runs. 3. Declarative directives are applied to the optimistic response. 4. On success: optimistic changes are rolled back, the server response is written, `updater` runs, directives are applied to the server data, and `onCompleted` is called. 5. On failure: optimistic changes are rolled back and `onError` is called. ## Pending state and errors The second element of `useMutation`'s tuple, `isInFlight`, is `true` while any mutation started by that hook is pending; disabling the like button with it prevents stacked optimistic updates. `commit` returns a disposable: disposing it reverts the optimistic update and skips the callbacks, although the network request is not guaranteed to be cancelled. Field errors in a successful payload reach `onCompleted` as its second argument, so a partially failed comment post can still be reported to the reader. ## When an updater is still needed - Updating a record the payload did not return, such as a comment count on a parent the server did not select. - Custom placement, such as inserting after a pinned comment with `ConnectionHandler.insertEdgeAfter`. - Changes that ripple too widely to select, such as hiding every comment by a blocked user: call `invalidateRecord()` or `store.invalidateStore()` in the updater so Relay treats the data as stale and refetches when it is next rendered. The principle across all of this: prefer a payload that returns the changed records, then declarative directives, and write an `updater` only for what those cannot express.
- Why do the Relay docs caution against computing likeCount inside an optimisticResponse when several likes can be in flight?Each optimistic response is computed from the store at the moment it is committed. If two are applied, the second was computed on top of the first; when the first is rolled back, the second's value, two above the original, stays in the store. The docs point to an `optimisticUpdater` for store-dependent values; disabling the button while `isInFlight` avoids the overlap entirely.
- Your schema's ids are unique only per type, so Article 42 and Comment 42 both exist. What happens in Relay, and how do you fix it?Relay's default `getDataID` keys records by the `id` value alone, so both objects map to one record and overwrite each other's fields. The durable fix is globally unique ids on the server, as the `Node` convention expects. On the client, pass a `getDataID(fieldValue, typeName)` to the Environment that combines the type name with the id.
- A 'block this commenter' mutation affects comments across many articles. How do you keep Relay's store correct?Selecting every affected record in the payload is impractical, so mark data stale in the `updater`: call `invalidateRecord()` on the affected records, or `store.invalidateStore()` for everything. Queries that read stale data refetch the next time they are evaluated, under their fetch policy.
saying these in an interview costs you the question
- Relay refetches every active query after each mutation.
- Without an updater, a Relay mutation response is never written to the store.
- Optimistic data stays in the store when the server returns an error.
- @prependEdge finds the right comment list by itself, with no connection ids.
- Relay's useMutation needs a refetchQueries option to refresh the thread.