Fix cookie mutations to update Relay store
The create, delete, and move cookie mutations were not updating the Relay store connections, so the UI only reflected changes after a page reload. Add @connection and @appendEdge/@deleteEdge directives, and a store updater for the move mutation. Also document mutation store update rules in contrib/claude/relay.md. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -33,7 +33,7 @@ import {
|
|||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFragment, useMutation } from "react-relay";
|
import { useFragment, useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { CategorySectionCreateCookieMutation } from "#/__generated__/core/CategorySectionCreateCookieMutation.graphql";
|
import type { CategorySectionCreateCookieMutation } from "#/__generated__/core/CategorySectionCreateCookieMutation.graphql";
|
||||||
import type { CategorySectionDeleteCookieMutation } from "#/__generated__/core/CategorySectionDeleteCookieMutation.graphql";
|
import type { CategorySectionDeleteCookieMutation } from "#/__generated__/core/CategorySectionDeleteCookieMutation.graphql";
|
||||||
@@ -58,7 +58,10 @@ export const categorySectionFragment = graphql`
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
kind
|
kind
|
||||||
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) @required(action: THROW) {
|
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
|
||||||
|
@connection(key: "CategorySection_cookies")
|
||||||
|
@required(action: THROW) {
|
||||||
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -109,14 +112,16 @@ const updateCategoryMutation = graphql`
|
|||||||
const createCookieMutation = graphql`
|
const createCookieMutation = graphql`
|
||||||
mutation CategorySectionCreateCookieMutation(
|
mutation CategorySectionCreateCookieMutation(
|
||||||
$input: CreateCookieInput!
|
$input: CreateCookieInput!
|
||||||
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
createCookie(input: $input) {
|
createCookie(input: $input) {
|
||||||
cookieEdge {
|
cookieEdge @appendEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
duration
|
duration
|
||||||
description
|
description
|
||||||
|
...EditCookieRowFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cookieBanner {
|
cookieBanner {
|
||||||
@@ -158,9 +163,10 @@ const updateCookieMutation = graphql`
|
|||||||
const deleteCookieMutation = graphql`
|
const deleteCookieMutation = graphql`
|
||||||
mutation CategorySectionDeleteCookieMutation(
|
mutation CategorySectionDeleteCookieMutation(
|
||||||
$input: DeleteCookieInput!
|
$input: DeleteCookieInput!
|
||||||
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
deleteCookie(input: $input) {
|
deleteCookie(input: $input) {
|
||||||
deletedCookieId
|
deletedCookieId @deleteEdge(connections: $connections)
|
||||||
cookieBanner {
|
cookieBanner {
|
||||||
id
|
id
|
||||||
latestVersion {
|
latestVersion {
|
||||||
@@ -225,6 +231,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
const [editingCookieId, setEditingCookieId] = useState<string | null>(null);
|
const [editingCookieId, setEditingCookieId] = useState<string | null>(null);
|
||||||
const [isAddingCookie, setIsAddingCookie] = useState(false);
|
const [isAddingCookie, setIsAddingCookie] = useState(false);
|
||||||
|
|
||||||
|
const cookiesConnectionId = category.cookies.__id;
|
||||||
const cookies = category.cookies.edges.map(e => e.node);
|
const cookies = category.cookies.edges.map(e => e.node);
|
||||||
const isMutating = isUpdating || isCreating || isUpdatingCookie;
|
const isMutating = isUpdating || isCreating || isUpdatingCookie;
|
||||||
|
|
||||||
@@ -276,6 +283,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
duration: cookie.duration,
|
duration: cookie.duration,
|
||||||
description: cookie.description,
|
description: cookie.description,
|
||||||
},
|
},
|
||||||
|
connections: [cookiesConnectionId],
|
||||||
},
|
},
|
||||||
onCompleted(_response, errors) {
|
onCompleted(_response, errors) {
|
||||||
if (errors?.length) {
|
if (errors?.length) {
|
||||||
@@ -360,6 +368,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
deleteCookie({
|
deleteCookie({
|
||||||
variables: {
|
variables: {
|
||||||
input: { cookieId },
|
input: { cookieId },
|
||||||
|
connections: [cookiesConnectionId],
|
||||||
},
|
},
|
||||||
onCompleted(_response, errors) {
|
onCompleted(_response, errors) {
|
||||||
if (errors?.length) {
|
if (errors?.length) {
|
||||||
@@ -400,6 +409,38 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
targetCookieCategoryId: targetCategoryId,
|
targetCookieCategoryId: targetCategoryId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
updater(store) {
|
||||||
|
const sourceCategory = store.get(category.id);
|
||||||
|
if (sourceCategory) {
|
||||||
|
const sourceConn = ConnectionHandler.getConnection(
|
||||||
|
sourceCategory,
|
||||||
|
"CategorySection_cookies",
|
||||||
|
);
|
||||||
|
if (sourceConn) {
|
||||||
|
ConnectionHandler.deleteNode(sourceConn, cookieId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCategory = store.get(targetCategoryId);
|
||||||
|
if (targetCategory) {
|
||||||
|
const targetConn = ConnectionHandler.getConnection(
|
||||||
|
targetCategory,
|
||||||
|
"CategorySection_cookies",
|
||||||
|
);
|
||||||
|
if (targetConn) {
|
||||||
|
const cookieRecord = store.get(cookieId);
|
||||||
|
if (cookieRecord) {
|
||||||
|
const newEdge = ConnectionHandler.createEdge(
|
||||||
|
store,
|
||||||
|
targetConn,
|
||||||
|
cookieRecord,
|
||||||
|
"CookieEdge",
|
||||||
|
);
|
||||||
|
ConnectionHandler.insertEdgeAfter(targetConn, newEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onCompleted(_response, errors) {
|
onCompleted(_response, errors) {
|
||||||
if (errors?.length) {
|
if (errors?.length) {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ The `@connection(key: "...", filters: [...])` directive on the fragment tells Re
|
|||||||
|
|
||||||
## Mutations
|
## Mutations
|
||||||
|
|
||||||
|
Every mutation **must** update the Relay store so the UI reflects changes immediately — never rely on a page reload. Use `@appendEdge`/`@prependEdge` for creates, `@deleteEdge` for deletes, node `id` returns for in-place updates, and `updater` functions for complex multi-connection operations.
|
||||||
|
|
||||||
### `useMutation`
|
### `useMutation`
|
||||||
|
|
||||||
Direct Relay hook for simple cases.
|
Direct Relay hook for simple cases.
|
||||||
@@ -335,10 +337,51 @@ const onSubmit = (formData: FormData) => {
|
|||||||
|
|
||||||
### Store update directives
|
### Store update directives
|
||||||
|
|
||||||
Relay directives handle connection updates automatically — no manual store manipulation needed:
|
Relay directives handle connection updates automatically — no manual store manipulation needed.
|
||||||
|
|
||||||
|
#### Connection setup
|
||||||
|
|
||||||
|
Any connection that a mutation will add to or remove from **must** have a `@connection` directive and expose `__id`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Add new edge to the beginning of a connection
|
const fragment = graphql`
|
||||||
|
fragment CategorySectionFragment on CookieCategory {
|
||||||
|
id
|
||||||
|
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
|
||||||
|
@connection(key: "CategorySection_cookies")
|
||||||
|
@required(action: THROW) {
|
||||||
|
__id
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
...EditCookieRowFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const category = useFragment(fragment, categoryKey);
|
||||||
|
const connectionId = category.cookies.__id;
|
||||||
|
```
|
||||||
|
|
||||||
|
When the mutation is triggered from a component that doesn't have access to the connection's `__id` (e.g. a sibling's child rather than a direct descendant), derive the connection ID with `ConnectionHandler.getConnectionID`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ConnectionHandler } from "relay-runtime";
|
||||||
|
|
||||||
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
|
parentNodeId, // the store ID of the node that owns the connection
|
||||||
|
"CategorySection_cookies", // the @connection key
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for dialogs, drawers, or other components rendered outside the subtree that reads the connection.
|
||||||
|
|
||||||
|
#### Directive examples
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Add new edge to a connection
|
||||||
const createMutation = graphql`
|
const createMutation = graphql`
|
||||||
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
|
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
|
||||||
createVendor(input: $input) {
|
createVendor(input: $input) {
|
||||||
@@ -361,7 +404,7 @@ const deleteMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Update in-place via fragment spread (no directive needed)
|
// Update in-place (Relay matches by id — no directive needed)
|
||||||
const updateMutation = graphql`
|
const updateMutation = graphql`
|
||||||
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
|
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
|
||||||
updateVendorContact(input: $input) {
|
updateVendorContact(input: $input) {
|
||||||
@@ -375,6 +418,55 @@ const updateMutation = graphql`
|
|||||||
|
|
||||||
The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment.
|
The `connections` variable is obtained from the `__id` field on the connection in the parent query/fragment.
|
||||||
|
|
||||||
|
#### Fragment spreads in create mutations
|
||||||
|
|
||||||
|
When a create mutation returns a new edge, its `node` selection **must** include all fragment spreads used by the list that renders it. This ensures the store has every field the UI needs to render the new item without a refetch:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad — missing fragment spread, child components will have missing data
|
||||||
|
cookieEdge @appendEdge(connections: $connections) {
|
||||||
|
node { id name duration description }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good — spreads the same fragment the list uses to render each item
|
||||||
|
cookieEdge @appendEdge(connections: $connections) {
|
||||||
|
node { id name duration description ...EditCookieRowFragment }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `updater` for complex store changes
|
||||||
|
|
||||||
|
When a single mutation affects multiple connections (e.g. moving an item between two lists) and the server payload doesn't return both an edge and a deletedId, use an `updater` function with `ConnectionHandler`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ConnectionHandler } from "relay-runtime";
|
||||||
|
|
||||||
|
moveCookie({
|
||||||
|
variables: { input: { cookieId, targetCookieCategoryId: targetId } },
|
||||||
|
updater(store) {
|
||||||
|
const source = store.get(sourceCategoryId);
|
||||||
|
if (source) {
|
||||||
|
const sourceConn = ConnectionHandler.getConnection(source, "CategorySection_cookies");
|
||||||
|
if (sourceConn) ConnectionHandler.deleteNode(sourceConn, cookieId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = store.get(targetId);
|
||||||
|
if (target) {
|
||||||
|
const targetConn = ConnectionHandler.getConnection(target, "CategorySection_cookies");
|
||||||
|
if (targetConn) {
|
||||||
|
const node = store.get(cookieId);
|
||||||
|
if (node) {
|
||||||
|
const edge = ConnectionHandler.createEdge(store, targetConn, node, "CookieEdge");
|
||||||
|
ConnectionHandler.insertEdgeAfter(targetConn, edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer declarative directives (`@appendEdge`, `@deleteEdge`) whenever possible; only fall back to `updater` when the operation cannot be expressed with directives alone.
|
||||||
|
|
||||||
### `useConfirm` for destructive actions
|
### `useConfirm` for destructive actions
|
||||||
|
|
||||||
Destructive mutations (delete) are wrapped with a confirmation dialog:
|
Destructive mutations (delete) are wrapped with a confirmation dialog:
|
||||||
|
|||||||
Reference in New Issue
Block a user