Update relay agent rules to mark both helpers as deprecated. Replace examples with the preferred pattern: useMutation with onCompleted/onError callbacks and useToast. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
7.8 KiB
Relay (Frontend GraphQL Client)
The console app uses Relay as its GraphQL client. All GraphQL operations are defined inline with the graphql template tag from relay-runtime — there are no separate .graphql files on the frontend.
Environments
Two Relay environments connect to two separate GraphQL APIs:
| Environment | Endpoint | Purpose |
|---|---|---|
coreEnvironment |
/api/console/v1/graphql |
Main application data |
iamEnvironment |
/api/connect/v1/graphql |
Authentication / identity |
Configured in apps/console/src/environments.ts. Each has its own store with 1-minute query cache expiration.
Relay compiler
Config lives in apps/console/relay.config.json with two projects (core, iam) mapped to different source directories and schemas. Generated files go into __generated__/ directories.
npm run relay # clean + compile
npm run relay-compile # compile only
Custom scalar mappings: Datetime → string, GID → string, CursorKey → string, Duration → string, BigInt → number, EmailAddr → string.
Colocated queries
Queries are defined inline in the file that uses them. Route-level queries are preloaded in the router loader before the component renders:
// In route definition
{
path: "vendors",
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
),
}
// In the component
export default function VendorsPage(props: Props) {
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
// ...
}
loaderFromQueryLoader— converts a query loader into a React Router loader, returns{ queryRef, dispose }withQueryRef— extractsqueryReffrom loader data and handles cleanup on unmount
For queries that need to run after render (e.g. select dropdowns), use useLazyLoadQuery with fetchPolicy: "network-only".
Fragments
Fragments colocate data requirements with the component that reads them:
const contactFragment = graphql`
fragment VendorContactsTabFragment_contact on VendorContact {
id
fullName
email
phone
role
createdAt
updatedAt
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
}
`;
function ContactRow(props: { contactKey: VendorContactsTabFragment_contact$key }) {
const contact = useFragment(contactFragment, props.contactKey);
// ...
}
Refetchable fragments
For lists that support sorting and pagination, use @refetchable with @argumentDefinitions:
const vendorContactsFragment = graphql`
fragment VendorContactsTabFragment on Vendor
@refetchable(queryName: "VendorContactsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorContactOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
contacts(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorContactsTabFragment_contacts") {
__id
edges {
node {
...VendorContactsTabFragment_contact
}
}
}
}
`;
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
const connectionId = data.contacts.__id;
Pagination
Use usePaginationFragment for cursor-based Relay pagination:
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
const connectionId = pagination.data.vendors.__id;
The @connection(key: "...", filters: [...]) directive on the fragment tells Relay how to manage the paginated list in the store. The filters array controls which variables affect the connection identity.
SortableTable is the standard component for rendering paginated, sortable lists — it receives pagination (with loadNext, hasNext, isLoadingNext) and a refetch callback for sorting.
Mutations
useMutation
Direct Relay hook for simple cases:
const [deleteVendor] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
For mutations with user feedback, combine with useToast and use onCompleted/onError callbacks:
const { toast } = useToast();
const [createObligation, isCreating] = useMutation<CreateObligationMutation>(createObligationMutation);
const onSubmit = (formData: FormData) => {
createObligation({
variables: {
input: { ...formData },
connections: [connectionId],
},
onCompleted() {
toast({
title: __("Success"),
description: __("Obligation created successfully"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create obligation"), error as GraphQLError),
variant: "error",
});
},
});
};
useMutationWithToasts (deprecated)
Do not use. Use useMutation combined with useToast instead.
promisifyMutation (deprecated)
Do not use. Use useMutation with onCompleted/onError callbacks instead of wrapping in a promise.
Store update directives
Relay directives handle connection updates automatically — no manual store manipulation needed:
// Add new edge to the beginning of a connection
const createMutation = graphql`
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
}
}
}
}
`;
// Remove an edge from a connection
const deleteMutation = graphql`
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
// Update in-place via fragment spread (no directive needed)
const updateMutation = graphql`
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) {
vendorContact {
...VendorContactsTabFragment_contact
}
}
}
`;
The connections variable is obtained from the __id field on the connection in the parent query/fragment.
useConfirm for destructive actions
Destructive mutations (delete) are wrapped with a confirmation dialog:
const confirm = useConfirm();
const [deleteVendor] = useMutation<DeleteVendorMutation>(deleteVendorMutation);
return () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteVendor({
variables: {
input: { vendorId: vendor.id! },
connections: [connectionId],
},
onCompleted() {
resolve();
},
onError() {
resolve();
},
});
}),
{ message: "Confirm deletion..." },
);
};
File organization
GraphQL operations are colocated with the components that use them:
pages/organizations/vendors/
VendorsPage.tsx # query + pagination fragment
tabs/
VendorContactsTab.tsx # refetchable fragment + item fragment
VendorComplianceTab.tsx
dialogs/
CreateContactDialog.tsx # create mutation
EditContactDialog.tsx # update mutation
hooks/graph/
VendorGraph.ts # shared queries, mutations, hooks
Shared queries and mutation hooks (used by multiple components) live in hooks/graph/*.ts. Component-specific operations are defined inline in the component file.