Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions mutations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
Note: I'm quite open to removing or deprioritising the optimistic updates here. It's a shame we don't have an example of `optimisticResponse`, and the others look a bit complicated. Not sure they really sell relay's abilities as much as we'd like

This is also AI generated as I'm out of time, not sure this is that useful. Feel free to discard this if not suitable

# Relay Workshop: Mutations

In this section, we'll implement the ability to add and remove reactions from pull requests. We'll cover:

1. Defining GraphQL mutations
2. Using the `useMutation` hook
3. Implementing **optimistic updates** for instant UI feedback
4. Using both standard Store updates and **Updatable Fragments** (maybe we omit one or both of these. I'd be nice to say that the order of preference for the APIS here is `optimisticResponse` -> `optimisticUpdater` with `@refetchable` -> `optimisticUpdater` with non-typesafe, old `store` apis)

## 1. Adding a Reaction (`AddReactionButton.tsx`)

We'll start by allowing users to add a reaction. Open `src/components/AddReactionButton.tsx`.

### Define the Mutation

First, we define the mutation to add a reaction. This mutation takes an `input` and returns the updated reaction object.

```typescript
const [commitAdd, isAddInFlight] = useMutation(graphql`
mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) {
addReaction(input: $input) {
reaction {
content
reactable {
...ReactableReactions_reactable
}
}
}
}
`);
```

### Implement the Handler with Optimistic Update

When the user selects an emoji, we want to update the UI immediately, before the server responds. We'll use Relay's **Updatable Fragments** feature for a clean optimistic update.

Ensure your fragment definition includes the `@updatable` directive on the `ReactionGroup` (this might already be in a separate file or defined locally):

```graphql
fragment AddReactionButton_updatable on ReactionGroup @updatable {
content
viewerHasReacted
reactors {
totalCount
}
}
```

Now, implement `handleAddReaction`:

```typescript
const handleAddReaction = (content: ReactionContent) => {
// Check if we already reacted
const reactionGroup = data.reactionGroups?.find(
(group) => group?.content === content
);

if (reactionGroup?.viewerHasReacted) {
return;
}

commitAdd({
variables: {
input: {
subjectId: data.id,
content: content,
},
},
// Optimistic Update using Updatable Fragments
optimisticUpdater: (store) => {
const reactionGroup = data.reactionGroups?.find(
(group) => group?.content === content
);

if (!reactionGroup || reactionGroup?.viewerHasReacted) {
setShowPicker(false);
return;
}

// Read the updatable fragment
const { updatableData } =
store.readUpdatableFragment<AddReactionButton_updatable$key>(
graphql`
fragment AddReactionButton_updatable on ReactionGroup @updatable {
content
viewerHasReacted
reactors {
totalCount
}
}
`,
reactionGroup
);

// Mutate the data directly
updatableData.viewerHasReacted = true;
updatableData.reactors.totalCount++;
},
onCompleted: () => setShowPicker(false),
});
};
```

## 2. Removing a Reaction (`ReactionGroup.tsx`)

Next, let's handle removing a reaction. Open `src/components/ReactionGroup.tsx`.

### Define the Mutation

```typescript
const [commitRemove, isRemoveInFlight] = useMutation(graphql`
mutation ReactionGroupRemoveReactionMutation($input: RemoveReactionInput!) {
removeReaction(input: $input) {
reaction {
content
reactable {
...ReactableReactions_reactable
}
}
}
}
`);
```

### Implement the Handler with Store API

For this example, we'll use the imperative **Store API** for the optimistic update. This is the traditional way to handle complex updates in Relay.

```typescript
const handleRemoveReaction = (content: ReactionContent) => {
commitRemove({
variables: {
input: {
subjectId: data.subject.id,
content: content,
},
},
optimisticUpdater: (store) => {
// 1. Get the subject record
const subject = store.get(data.subject.id);

// 2. Traverse to the reaction groups
const groups = subject?.getLinkedRecords("reactionGroups");
const group = groups?.find((g) => g.getValue("content") === data.content);

// 3. Update the values
const reactors = group?.getLinkedRecord("reactors");
const totalCount = Number(reactors?.getValue("totalCount")) ?? 0;

reactors?.setValue(totalCount - 1, "totalCount");
group?.setValue(false, "viewerHasReacted"); // Set to false since we're removing
},
});
};
```

## Summary

- **`useMutation`**: The primary hook for triggering mutations.
- **Optimistic Updates**: Crucial for a snappy user experience.
- **Updatable Fragments**: A newer, more declarative API for local data updates (`readUpdatableFragment`).
- **Store API**: The imperative API for manual store manipulation (`store.get`, `setValue`, etc.).
126 changes: 126 additions & 0 deletions src/components/AddReactionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import { graphql, useFragment, useMutation } from "react-relay";
import { AddReactionButton_reactable$key } from "./__generated__/AddReactionButton_reactable.graphql";
import { AddReactionButton_updatable$key } from "./__generated__/AddReactionButton_updatable.graphql";
import {
REACTION_TYPES,
getReactionEmoji,
ReactionContent,
} from "../lib/reactions";

type Props = {
reactable: AddReactionButton_reactable$key;
};

const AddReactionButton = ({ reactable }: Props) => {
const data = useFragment(
graphql`
fragment AddReactionButton_reactable on Reactable {
id
reactionGroups {
content
viewerHasReacted
...AddReactionButton_updatable
}
}
`,
reactable
);

const [showPicker, setShowPicker] = useState(false);

const [commitAdd, isAddInFlight] = useMutation(graphql`
mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) {
addReaction(input: $input) {
reaction {
content
reactable {
...ReactableReactions_reactable
}
}
}
}
`);

const handleAddReaction = (content: ReactionContent) => {
const reactionGroup = data.reactionGroups?.find(
(group) => group?.content === content
);

if (reactionGroup?.viewerHasReacted) {
return;
}

commitAdd({
variables: {
input: {
subjectId: data.id,
content: content,
},
},
optimisticUpdater: (store) => {
const reactionGroup = data.reactionGroups?.find(
(group) => group?.content === content
);
if (!reactionGroup || reactionGroup?.viewerHasReacted) {
setShowPicker(false);
return;
}

const { updatableData } =
store.readUpdatableFragment<AddReactionButton_updatable$key>(
graphql`
fragment AddReactionButton_updatable on ReactionGroup @updatable {
content
viewerHasReacted
reactors {
totalCount
}
}
`,
reactionGroup
);

updatableData.viewerHasReacted = true;
updatableData.reactors.totalCount++;
},
onCompleted: () => setShowPicker(false),
});
};

return (
<div className="relative">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowPicker(!showPicker);
}}
className="inline-flex items-center justify-center w-6 h-6 text-xs border border-zinc-200 dark:border-zinc-700 rounded-full bg-zinc-50 dark:bg-zinc-800 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors cursor-pointer"
>
+
</button>

{showPicker && (
<div className="absolute top-full left-0 mt-1 p-1 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg flex gap-1 z-10">
{REACTION_TYPES.map((type) => (
<button
key={type}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddReaction(type);
}}
disabled={isAddInFlight}
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded transition-colors cursor-pointer"
>
{getReactionEmoji(type)}
</button>
))}
</div>
)}
</div>
);
};

export default AddReactionButton;
4 changes: 4 additions & 0 deletions src/components/PullRequest.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { graphql, useFragment } from "react-relay";
import { PullRequest_pr$key } from "./__generated__/PullRequest_pr.graphql";
import ReactableReactions from "./ReactableReactions";

const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => {
const data = useFragment(
Expand All @@ -22,6 +23,7 @@ const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => {
additions
deletions
reviewDecision
...ReactableReactions_reactable
}
`,
pr
Expand Down Expand Up @@ -98,6 +100,8 @@ const PullRequest = ({ pr }: { pr: PullRequest_pr$key }) => {
</div>
</div>

<ReactableReactions reactable={data} />

<div className="flex items-center gap-4 text-sm text-zinc-500 dark:text-zinc-500">
<div className="flex items-center gap-1">
<span className="text-green-600 dark:text-green-400">
Expand Down
39 changes: 39 additions & 0 deletions src/components/ReactableReactions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { graphql, useFragment } from "react-relay";
import AddReactionButton from "./AddReactionButton";
import ReactionGroup from "./ReactionGroup";
import { ReactableReactions_reactable$key } from "./__generated__/ReactableReactions_reactable.graphql";

type Props = {
reactable: ReactableReactions_reactable$key;
};

const ReactableReactions = ({ reactable }: Props) => {
const data = useFragment(
graphql`
fragment ReactableReactions_reactable on Reactable {
...AddReactionButton_reactable
reactionGroups {
content
...ReactionGroup_group
}
}
`,
reactable
);

if (!data.reactionGroups) {
return null;
}

return (
<div className="flex gap-2 mb-3 items-center relative">
{data.reactionGroups.map((group) => (
<ReactionGroup key={group.content} group={group} />
))}

<AddReactionButton reactable={data} />
</div>
);
};

export default ReactableReactions;
Loading