web-data-fetching-graphql-apollo ยท diff
git:20260202.b236384 to git:20260316.9090a29
180 added, 744 removed. Audit A to A.
---
name: web-data-fetching-graphql-apollo
description: Apollo Client GraphQL patterns - useQuery, useMutation, cache management, optimistic updates, subscriptions
---
# Apollo Client GraphQL Patterns
- > **Quick Guide:** Use Apollo Client for GraphQL APIs. Provides automatic caching, optimistic updates, and real-time subscriptions. Use GraphQL Codegen for type safety. **v3.9+** adds Suspense hooks (`useSuspenseQuery`, `useLoadableQuery`); **v4.0** moves React imports to `@apollo/client/react`.
+ > **Quick Guide:** Use Apollo Client for GraphQL APIs. Provides automatic normalized caching, optimistic updates, and real-time subscriptions. Always use GraphQL Codegen for type safety. Configure `keyFields` on every entity type for proper cache normalization. Use `errorPolicy: "all"` for graceful degradation. **v3.9+** adds Suspense hooks; **v4.0** moves React imports to `@apollo/client/react` and adds `dataState` for type-safe query state.
---
<critical_requirements>
## CRITICAL: Before Using This Skill
**(You MUST use GraphQL Codegen for type generation - NEVER write manual TypeScript types for GraphQL)**
**(You MUST include `__typename` and `id` in all optimistic responses for cache normalization)**
- **(You MUST configure type policies with appropriate `keyFields` for cache identification)**
+ **(You MUST configure type policies with appropriate `keyFields` for every entity type)**
**(You MUST use named constants for ALL timeout, retry, and polling values - NO magic numbers)**
**(You MUST use named exports only - NO default exports)**
</critical_requirements>
---
**Auto-detection:** Apollo Client, useQuery, useMutation, useSubscription, useSuspenseQuery, useLoadableQuery, useBackgroundQuery, useFragment, ApolloClient, InMemoryCache, gql, GraphQL, optimistic updates, cache policies, createQueryPreloader
**When to use:**
- Fetching data from GraphQL APIs
- Real-time updates with GraphQL subscriptions
- Complex cache management with normalized data
- Optimistic UI updates for mutations
- - Applications already using GraphQL server
+ - Applications already using a GraphQL server
**When NOT to use:**
- REST APIs (use your data fetching solution instead)
- Simple APIs without caching needs (consider fetch directly)
- When GraphQL Codegen cannot be integrated
**Key patterns covered:**
- Client setup with InMemoryCache and type policies
- - useQuery for queries with loading, error, and data states
- - useMutation with optimistic updates and cache modification
+ - useQuery / useLazyQuery for queries with loading, error, and data states
+ - useMutation with optimistic updates, cache.modify, and cache.evict
- useSubscription for real-time WebSocket data
- Pagination with fetchMore and relayStylePagination
- - Fragment colocation with useFragment
- - GraphQL Codegen integration for type safety
- - Local state with reactive variables
- - **v3.9+**: useSuspenseQuery, useLoadableQuery, useBackgroundQuery for Suspense
- - **v3.9+**: createQueryPreloader for preloading outside React
- - **v3.10+**: Schema-based testing with createTestSchema
+ - Fragment colocation and useFragment
+ - Reactive variables for local client state
+ - Suspense hooks: useSuspenseQuery, useLoadableQuery, useBackgroundQuery, createQueryPreloader
**Detailed Resources:**
- - For code examples, see [examples/core.md](examples/core.md)
- - For v3.9+ Suspense patterns, see [examples/suspense.md](examples/suspense.md)
- - For decision frameworks and anti-patterns, see [reference.md](reference.md)
+ - [examples/core.md](examples/core.md) - Client setup, useQuery, useMutation with cache updates
+ - [examples/pagination.md](examples/pagination.md) - Infinite scroll, relay pagination type policies
+ - [examples/fragments.md](examples/fragments.md) - Fragment definitions, composition, colocation
+ - [examples/error-handling.md](examples/error-handling.md) - Component-level and global error handling
+ - [examples/subscriptions.md](examples/subscriptions.md) - WebSocket link setup, useSubscription with cache updates
+ - [examples/testing.md](examples/testing.md) - MockedProvider, component tests, schema-based testing
+ - [examples/suspense.md](examples/suspense.md) - v3.9+ Suspense hooks (useSuspenseQuery, useLoadableQuery, useBackgroundQuery)
+ - [reference.md](reference.md) - Decision frameworks, API reference tables, anti-patterns
---
<philosophy>
## Philosophy
- Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. It provides intelligent caching that normalizes your data, reducing redundant network requests and keeping your UI consistent.
+ Apollo Client is a comprehensive GraphQL client that provides intelligent normalized caching, reducing redundant network requests and keeping your UI consistent across components.
**Core Principles:**
- 1. **Normalized Cache**: Data is stored once by type and ID, referenced everywhere
- 2. **Declarative Data Fetching**: Components declare what data they need, Apollo handles the how
- 3. **Optimistic UI**: Show expected results immediately, rollback on server error
- 4. **Type Safety**: Use GraphQL Codegen to generate TypeScript types from your schema
+ 1. **Normalized Cache**: Data is stored once by type and ID, referenced everywhere - update in one place, UI reflects everywhere
+ 2. **Declarative Data Fetching**: Components declare what data they need via GraphQL, Apollo handles caching, deduplication, and network
+ 3. **Optimistic UI**: Show expected results immediately, rollback automatically on server error
+ 4. **Type Safety**: GraphQL Codegen generates TypeScript types from your schema - never write response types manually
- **Apollo Client's Data Flow:**
+ **Data Flow:**
1. Component requests data via useQuery/useMutation
- 2. Apollo checks InMemoryCache first
+ 2. Apollo checks InMemoryCache (normalized by `__typename` + `keyFields`)
3. If cache miss or stale, fetches from network
4. Response is normalized and stored in cache
- 5. All components watching that data re-render
+ 5. All components watching that data re-render automatically
</philosophy>
---
<patterns>
## Core Patterns
### Pattern 1: Client Setup and Configuration
- Configure ApolloClient with InMemoryCache and appropriate type policies for cache normalization.
-
- #### Constants
-
- ```typescript
- const GRAPHQL_ENDPOINT =
- process.env.NEXT_PUBLIC_GRAPHQL_URL || "http://localhost:4000/graphql";
- const DEFAULT_POLL_INTERVAL_MS = 30 * 1000;
- ```
-
- #### Implementation
+ Configure ApolloClient with InMemoryCache, type policies for cache normalization, and link chain for error handling and auth. Environment variables should use your framework's convention for the GraphQL endpoint.
```typescript
- // lib/apollo-client.ts
- import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
- import { onError } from "@apollo/client/link/error";
-
- const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_GRAPHQL_URL || "";
-
- // Error handling link
- const errorLink = onError(({ graphQLErrors, networkError }) => {
- if (graphQLErrors) {
- graphQLErrors.forEach(({ message, locations, path }) => {
- console.error(
- `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
- );
- });
- }
- if (networkError) {
- console.error(`[Network error]: ${networkError}`);
- }
- });
-
- // HTTP link
- const httpLink = new HttpLink({
- uri: GRAPHQL_ENDPOINT,
- credentials: "include",
- });
-
- // Cache with type policies
const cache = new InMemoryCache({
typePolicies: {
+ User: { keyFields: ["id"] },
+ Product: { keyFields: ["sku"] }, // Non-default identifier
+ CartItem: { keyFields: false }, // Embed in parent, don't normalize
Query: {
fields: {
- // Merge paginated results
- users: {
- keyArgs: ["filter"],
- merge(existing = [], incoming) {
- return [...existing, ...incoming];
- },
- },
+ usersConnection: relayStylePagination(["filter"]),
},
},
- User: {
- // Identify users by their ID
- keyFields: ["id"],
- },
- Product: {
- // Identify products by SKU (not default id)
- keyFields: ["sku"],
- },
},
});
-
- const apolloClient = new ApolloClient({
- link: from([errorLink, httpLink]),
- cache,
- defaultOptions: {
- watchQuery: {
- fetchPolicy: "cache-and-network",
- errorPolicy: "all",
- },
- query: {
- fetchPolicy: "cache-first",
- errorPolicy: "all",
- },
- mutate: {
- errorPolicy: "all",
- },
- },
- });
-
- export { apolloClient };
```
- **Why good:** Environment variables enable different GraphQL endpoints per environment, type policies ensure proper cache normalization, error link provides centralized error handling, named exports enable tree-shaking
+ **Key decisions:** `keyFields` determines how entities are identified in cache. Use `["id"]` (default), custom field like `["sku"]`, composite `["authorId", "postId"]`, or `false` for embedded types.
+ See [examples/core.md](examples/core.md) Pattern 1 for complete client setup with auth link, error link, and codegen configuration.
+
---
### Pattern 2: useQuery for Data Fetching
- Use useQuery to fetch data declaratively with loading, error, and data states.
-
- #### Implementation
+ Declare data requirements with `useQuery`. Always handle loading, error, and empty states. Use `cache-and-network` for stale-while-revalidate behavior.
```typescript
- // components/user-list.tsx
- import { useQuery, gql } from "@apollo/client";
- import type { GetUsersQuery, GetUsersQueryVariables } from "@/generated/graphql";
-
- const GET_USERS = gql`
- query GetUsers($limit: Int!, $offset: Int) {
- users(limit: $limit, offset: $offset) {
- id
- name
- email
- avatar
- }
- }
- `;
-
- const DEFAULT_PAGE_SIZE = 20;
- const INITIAL_OFFSET = 0;
-
- function UserList() {
- const { data, loading, error, refetch } = useQuery<GetUsersQuery, GetUsersQueryVariables>(
- GET_USERS,
- {
- variables: {
- limit: DEFAULT_PAGE_SIZE,
- offset: INITIAL_OFFSET,
- },
- // Control fetch behavior
- fetchPolicy: "cache-and-network",
- // Notify on network status changes
- notifyOnNetworkStatusChange: true,
- }
- );
-
- if (loading && !data) {
- return <Skeleton />;
- }
-
- if (error) {
- return <Error message={error.message} onRetry={() => refetch()} />;
- }
-
- if (!data?.users?.length) {
- return <EmptyState message="No users found" />;
+ const { data, loading, error, refetch } = useQuery<GetUsersQuery, GetUsersQueryVariables>(
+ GET_USERS,
+ {
+ variables: { limit: DEFAULT_PAGE_SIZE },
+ fetchPolicy: "cache-and-network",
+ skip: !shouldFetch,
}
-
- return (
- <ul>
- {data.users.map((user) => (
- <li key={user.id}>{user.name}</li>
- ))}
- </ul>
- );
- }
+ );
- export { UserList };
+ if (loading && !data) return <Skeleton />;
+ if (error) return <Error message={error.message} onRetry={() => refetch()} />;
+ if (!data?.users?.length) return <EmptyState />;
```
- **Why good:** TypeScript generics provide full type safety, named constants make pagination configurable, refetch enables user-triggered refresh, cache-and-network shows cached data while fetching fresh
+ **Why this pattern:** `loading && !data` shows skeleton only on initial load (not background refetch). `cache-and-network` shows cached data immediately while refreshing from network.
- ---
+ See [examples/core.md](examples/core.md) Pattern 2 for complete useQuery and useLazyQuery examples.
- ### Pattern 3: useMutation with Optimistic Updates
+ ---
- Use useMutation for data modifications with optimistic UI for instant feedback.
+ ### Pattern 3: useMutation with Optimistic Updates and Cache Updates
- #### Implementation
+ For mutations, decide between three cache update strategies: optimistic response (instant UI), `update` callback with `cache.modify` (manual cache update), or `refetchQueries` (simple but costs a network request).
```typescript
- // components/add-comment.tsx
- import { useState } from "react";
- import type { FormEvent } from "react";
- import { useMutation, gql } from "@apollo/client";
- import type { AddCommentMutation, AddCommentMutationVariables } from "@/generated/graphql";
-
- const ADD_COMMENT = gql`
- mutation AddComment($postId: ID!, $content: String!) {
- addComment(postId: $postId, content: $content) {
- id
- content
- author {
- id
- name
- }
- createdAt
- }
- }
- `;
-
- const GET_POST_COMMENTS = gql`
- query GetPostComments($postId: ID!) {
- post(id: $postId) {
- id
- comments {
- id
- content
- author {
- id
- name
- }
- createdAt
- }
- }
- }
- `;
-
- interface AddCommentFormProps {
- postId: string;
- currentUser: { id: string; name: string };
- }
-
- function AddCommentForm({ postId, currentUser }: AddCommentFormProps) {
- const [content, setContent] = useState("");
-
- const [addComment, { loading }] = useMutation<AddCommentMutation, AddCommentMutationVariables>(
- ADD_COMMENT,
- {
- // Optimistic response for instant UI update
- optimisticResponse: {
- addComment: {
- __typename: "Comment",
- id: `temp-${Date.now()}`,
- content,
- author: {
- __typename: "User",
- id: currentUser.id,
- name: currentUser.name,
- },
- createdAt: new Date().toISOString(),
+ const [createPost] = useMutation(CREATE_POST, {
+ optimisticResponse: {
+ createPost: {
+ __typename: "Post", // REQUIRED for normalization
+ id: `temp-${Date.now()}`, // Temporary ID, replaced by server response
+ title,
+ content,
+ },
+ },
+ update(cache, { data }) {
+ cache.modify({
+ fields: {
+ posts(existing = [], { toReference }) {
+ return [toReference(data.createPost), ...existing];
},
},
- // Update cache after mutation
- update(cache, { data }) {
- if (!data?.addComment) return;
-
- // Read existing comments
- const existing = cache.readQuery({
- query: GET_POST_COMMENTS,
- variables: { postId },
- });
-
- // Write new comment to cache
- cache.writeQuery({
- query: GET_POST_COMMENTS,
- variables: { postId },
- data: {
- post: {
- ...existing?.post,
- comments: [...(existing?.post?.comments || []), data.addComment],
- },
- },
- });
- },
- onError(error) {
- console.error("Failed to add comment:", error);
- toast.error("Failed to add comment. Please try again.");
- },
- }
- );
-
- const handleSubmit = async (e: FormEvent) => {
- e.preventDefault();
- if (!content.trim()) return;
-
- await addComment({ variables: { postId, content } });
- setContent("");
- };
-
- return (
- <form onSubmit={handleSubmit}>
- <textarea
- value={content}
- onChange={(e) => setContent(e.target.value)}
- placeholder="Write a comment..."
- disabled={loading}
- />
- <button type="submit" disabled={loading || !content.trim()}>
- {loading ? "Posting..." : "Post Comment"}
- </button>
- </form>
- );
- }
-
- export { AddCommentForm };
+ });
+ },
+ });
```
- **Why good:** Optimistic response includes `__typename` and `id` for proper cache normalization, update callback manually updates related queries, onError provides user feedback on failure, UI remains responsive during network request
+ **Critical:** Always include `__typename` and `id` in optimistic responses. For deletes, use `cache.evict()` + `cache.gc()`. For simple cases, `refetchQueries` is fine.
+ See [examples/core.md](examples/core.md) Pattern 3 for create, update, and delete mutation examples.
+
---
### Pattern 4: Cache Type Policies
- Configure type policies for proper cache normalization and custom field behaviors.
-
- #### Implementation
+ Type policies control how Apollo normalizes and retrieves cached data. This is where you configure cache identifiers, computed fields, pagination merging, and local state.
```typescript
- // lib/apollo-cache.ts
- import { InMemoryCache, Reference, makeVar } from "@apollo/client";
- import { relayStylePagination } from "@apollo/client/utilities";
-
- // Reactive variable for local state
- const isLoggedInVar = makeVar<boolean>(false);
- const cartItemsVar = makeVar<string[]>([]);
-
- const cache = new InMemoryCache({
- typePolicies: {
- Query: {
- fields: {
- // Relay-style pagination for connections
- usersConnection: relayStylePagination(["filter", "sortBy"]),
-
- // Custom read for local state
- isLoggedIn: {
- read() {
- return isLoggedInVar();
- },
- },
-
- // Singleton pattern (no keyArgs needed)
- currentUser: {
- merge(existing, incoming) {
- return incoming;
- },
- },
- },
- },
-
- User: {
- // Default cache key is id, this is explicit
- keyFields: ["id"],
- fields: {
- // Computed field
- fullName: {
- read(_, { readField }) {
- const firstName = readField<string>("firstName");
- const lastName = readField<string>("lastName");
- return `${firstName} ${lastName}`;
- },
+ typePolicies: {
+ User: {
+ keyFields: ["id"],
+ fields: {
+ fullName: {
+ read(_, { readField }) {
+ return `${readField("firstName")} ${readField("lastName")}`;
},
},
},
-
- Product: {
- // Use SKU instead of id for cache key
- keyFields: ["sku"],
- },
-
- CartItem: {
- // Don't normalize (embed in parent)
- keyFields: false,
- },
-
- // Interface-level policy (applies to all implementing types)
- Node: {
- keyFields: ["id"],
+ },
+ Query: {
+ fields: {
+ isLoggedIn: { read() { return isLoggedInVar(); } },
},
},
- });
-
- export { cache, isLoggedInVar, cartItemsVar };
+ }
```
- **Why good:** relayStylePagination handles cursor-based pagination automatically, keyFields configure proper cache identification, read functions enable computed and local state fields, keyFields: false prevents unnecessary normalization for embedded types
+ **Key patterns:** `keyFields` for identification, `merge` for pagination, `read` for computed/local fields, `keyArgs` for separating cache entries per filter.
+ See [examples/core.md](examples/core.md) Pattern 1 and [examples/pagination.md](examples/pagination.md) for type policy examples.
+
---
### Pattern 5: Pagination with fetchMore
- Implement cursor-based pagination using fetchMore and type policies.
-
- #### Implementation
+ Two approaches: **Relay-style** (cursor-based, use `relayStylePagination`) and **offset-based** (custom merge/read functions). Both require type policies for merging.
```typescript
- // components/paginated-users.tsx
- import { useQuery, gql } from "@apollo/client";
-
- const GET_USERS_CONNECTION = gql`
- query GetUsersConnection($first: Int!, $after: String) {
- usersConnection(first: $first, after: $after) {
- edges {
- cursor
- node {
- id
- name
- email
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- `;
-
- const PAGE_SIZE = 20;
+ const { data, fetchMore } = useQuery(GET_USERS_CONNECTION, {
+ variables: { first: PAGE_SIZE },
+ });
- function PaginatedUserList() {
- const { data, loading, error, fetchMore } = useQuery(GET_USERS_CONNECTION, {
- variables: { first: PAGE_SIZE },
- notifyOnNetworkStatusChange: true,
+ const loadMore = () =>
+ fetchMore({
+ variables: { after: data.usersConnection.pageInfo.endCursor },
});
-
- const loadMore = () => {
- if (!data?.usersConnection?.pageInfo?.hasNextPage) return;
-
- fetchMore({
- variables: {
- after: data.usersConnection.pageInfo.endCursor,
- },
- // Type policy handles merging automatically with relayStylePagination
- });
- };
-
- if (loading && !data) {
- return <Skeleton />;
- }
-
- if (error) {
- return <Error message={error.message} />;
- }
-
- const users = data?.usersConnection?.edges?.map((edge) => edge.node) || [];
- const hasNextPage = data?.usersConnection?.pageInfo?.hasNextPage || false;
-
- return (
- <div>
- <ul>
- {users.map((user) => (
- <li key={user.id}>{user.name}</li>
- ))}
- </ul>
- {hasNextPage && (
- <button onClick={loadMore} disabled={loading}>
- {loading ? "Loading..." : "Load More"}
- </button>
- )}
- </div>
- );
- }
-
- export { PaginatedUserList };
```
- **Why good:** fetchMore appends new data to existing cache, relayStylePagination type policy handles merge automatically, hasNextPage prevents unnecessary requests, loading state during fetch prevents double-clicks
+ **Key requirement:** `keyArgs` must be set to separate cache entries per filter. Without it, different filtered queries overwrite each other.
+ See [examples/pagination.md](examples/pagination.md) for infinite scroll with IntersectionObserver and custom offset pagination type policies.
+
---
### Pattern 6: Fragment Colocation
- Colocate data requirements with components using fragments.
-
- #### Implementation
+ Colocate data requirements with components using fragments. Parent queries include child fragments, so component changes don't require updating parent queries.
```typescript
- // components/user-card.tsx
- import { gql } from "@apollo/client";
- import type { UserCardFragment } from "@/generated/graphql";
-
- // Colocated fragment - defines component's data needs
const USER_CARD_FRAGMENT = gql`
fragment UserCard on User {
id
name
email
avatar
- role
}
`;
- interface UserCardProps {
- user: UserCardFragment;
- }
-
- function UserCard({ user }: UserCardProps) {
- return (
- <div className="user-card">
- <img src={user.avatar} alt={user.name} />
- <h3>{user.name}</h3>
- <p>{user.email}</p>
- <span>{user.role}</span>
- </div>
- );
- }
-
- // Attach fragment to component for parent queries
- UserCard.fragments = {
- user: USER_CARD_FRAGMENT,
- };
-
- export { UserCard };
- ```
-
- ```typescript
- // components/user-list.tsx
- import { useQuery, gql } from "@apollo/client";
- import { UserCard } from "./user-card";
-
// Parent query includes child fragment
const GET_USERS = gql`
query GetUsers {
users {
...UserCard
}
}
${UserCard.fragments.user}
`;
-
- function UserListPage() {
- const { data, loading, error } = useQuery(GET_USERS);
-
- if (loading) return <Skeleton />;
- if (error) return <Error message={error.message} />;
-
- return (
- <div>
- {data?.users?.map((user) => (
- <UserCard key={user.id} user={user} />
- ))}
- </div>
- );
- }
-
- export { UserListPage };
```
- **Why good:** Fragment colocation keeps data requirements close to components, parent queries automatically include child data needs, TypeScript types generated for fragments ensure type safety, component changes don't require updating parent queries
+ See [examples/fragments.md](examples/fragments.md) for fragment composition and [examples/core.md](examples/core.md) Pattern 2 for fragments in queries.
---
- ### Pattern 7: useSubscription for Real-Time Data
-
- Set up WebSocket subscriptions for real-time updates.
+ ### Pattern 7: Subscriptions for Real-Time Data
- #### Link Configuration
+ Requires split link configuration: WebSocket for subscriptions, HTTP for queries/mutations. Use `graphql-ws` (not the deprecated `subscriptions-transport-ws`).
```typescript
- // lib/apollo-client.ts
- import { ApolloClient, InMemoryCache, HttpLink, split } from "@apollo/client";
- import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
- import { createClient } from "graphql-ws";
- import { getMainDefinition } from "@apollo/client/utilities";
-
- const GRAPHQL_HTTP_URL = process.env.NEXT_PUBLIC_GRAPHQL_URL || "";
- const GRAPHQL_WS_URL = process.env.NEXT_PUBLIC_GRAPHQL_WS_URL || "";
-
- const httpLink = new HttpLink({
- uri: GRAPHQL_HTTP_URL,
- });
-
- const wsLink = new GraphQLWsLink(
- createClient({
- url: GRAPHQL_WS_URL,
- connectionParams: () => ({
- // Add auth token if needed
- authToken: localStorage.getItem("token"),
- }),
- }),
- );
-
- // Split traffic between WebSocket (subscriptions) and HTTP (queries/mutations)
const splitLink = split(
({ query }) => {
- const definition = getMainDefinition(query);
+ const def = getMainDefinition(query);
return (
- definition.kind === "OperationDefinition" &&
- definition.operation === "subscription"
+ def.kind === "OperationDefinition" && def.operation === "subscription"
);
},
wsLink,
httpLink,
);
-
- const apolloClient = new ApolloClient({
- link: splitLink,
- cache: new InMemoryCache(),
- });
-
- export { apolloClient };
```
- #### Subscription Usage
-
- ```typescript
- // components/live-comments.tsx
- import { useSubscription, gql } from "@apollo/client";
-
- const COMMENT_ADDED = gql`
- subscription OnCommentAdded($postId: ID!) {
- commentAdded(postId: $postId) {
- id
- content
- author {
- id
- name
- }
- createdAt
- }
- }
- `;
-
- interface LiveCommentsProps {
- postId: string;
- }
-
- function LiveComments({ postId }: LiveCommentsProps) {
- const { data, loading, error } = useSubscription(COMMENT_ADDED, {
- variables: { postId },
- onData({ data }) {
- // Handle new comment (e.g., play sound, show notification)
- console.log("New comment received:", data.data?.commentAdded);
- },
- onError(error) {
- console.error("Subscription error:", error);
- },
- });
-
- if (error) {
- return <div>Subscription error: {error.message}</div>;
- }
-
- return (
- <div>
- {loading && <span>Listening for new comments...</span>}
- {data?.commentAdded && (
- <div className="new-comment">
- <strong>{data.commentAdded.author.name}:</strong>
- <p>{data.commentAdded.content}</p>
- </div>
- )}
- </div>
- );
- }
-
- export { LiveComments };
- ```
+ **Important:** Only create `wsLink` on the client side (`typeof window !== "undefined"`). Update cache in `onData` callback.
- **Why good:** Link splitting routes subscriptions to WebSocket and queries/mutations to HTTP, connectionParams enables authenticated subscriptions, onData callback handles side effects for new data
+ See [examples/subscriptions.md](examples/subscriptions.md) for complete WebSocket setup and useSubscription with cache updates.
---
### Pattern 8: Local State with Reactive Variables
- Use reactive variables for client-side state that needs to integrate with Apollo queries.
-
- #### Implementation
+ Use `makeVar` for simple client-side state that integrates with Apollo's reactivity system. Suitable for theme, auth status, cart items - not complex state.
```typescript
- // lib/local-state.ts
- import { makeVar } from "@apollo/client";
-
- // Theme preference
- const themeVar = makeVar<"light" | "dark">("light");
-
- // Shopping cart items
const cartItemsVar = makeVar<string[]>([]);
-
- // Auth state
- const isAuthenticatedVar = makeVar<boolean>(false);
-
- // Helpers for cart operations
- const addToCart = (productId: string) => {
- const current = cartItemsVar();
- if (!current.includes(productId)) {
- cartItemsVar([...current, productId]);
- }
- };
-
- const removeFromCart = (productId: string) => {
- const current = cartItemsVar();
- cartItemsVar(current.filter((id) => id !== productId));
- };
-
- const clearCart = () => {
- cartItemsVar([]);
- };
-
- export {
- themeVar,
- cartItemsVar,
- isAuthenticatedVar,
- addToCart,
- removeFromCart,
- clearCart,
- };
- ```
-
- ```typescript
- // components/cart-badge.tsx
- import { useReactiveVar } from "@apollo/client";
- import { cartItemsVar } from "@/lib/local-state";
-
- function CartBadge() {
- // Re-renders automatically when cartItemsVar changes
- const cartItems = useReactiveVar(cartItemsVar);
-
- return (
- <div className="cart-badge">
- <span>Cart</span>
- {cartItems.length > 0 && (
- <span className="badge">{cartItems.length}</span>
- )}
- </div>
- );
- }
+ const addToCart = (id: string) => cartItemsVar([...cartItemsVar(), id]);
- export { CartBadge };
+ // Component reacts automatically
+ const cartItems = useReactiveVar(cartItemsVar);
```
- **Why good:** Reactive variables provide simple local state without Redux complexity, useReactiveVar hook triggers re-renders on changes, can be queried via type policies if needed, helper functions encapsulate state mutations
+ **When to use reactive vars vs external state management:** Reactive vars for simple Apollo-integrated state. For complex non-GraphQL state, use your client state management solution.
---
- ### Pattern 9: Suspense with useSuspenseQuery (v3.9+)
+ ### Pattern 9: Suspense Hooks (v3.9+)
- Use `useSuspenseQuery` for Suspense-enabled data fetching that integrates with React's concurrent features.
+ Four Suspense-enabled hooks for different loading patterns:
- See [examples/suspense.md](examples/suspense.md) for complete implementation patterns including:
+ | Hook | Trigger | Use Case |
+ | ---------------------- | ---------------- | ---------------------------- |
+ | `useSuspenseQuery` | Component mount | Standard data loading |
+ | `useLoadableQuery` | User interaction | Hover/click prefetch |
+ | `useBackgroundQuery` | Parent mount | Parent triggers, child reads |
+ | `createQueryPreloader` | Route transition | Router loader integration |
- - `useSuspenseQuery` for component-level Suspense
- - `useLoadableQuery` for user-interaction triggered loading
- - `useBackgroundQuery` + `useReadQuery` for background loading
- - `createQueryPreloader` for preloading outside React (router integration)
+ **Key difference from useQuery:** No `loading` state - component suspends instead. Errors throw to Error Boundary.
+ See [examples/suspense.md](examples/suspense.md) for complete examples of all four patterns.
+
---
### Pattern 10: useFragment for Data Masking (v3.8+)
- Use `useFragment` to read fragment data directly from the cache with automatic updates.
+ Read fragment data directly from cache with automatic updates. Useful for components that only need a subset of cached entity data.
```typescript
- // components/user-card.tsx
- import { useFragment, gql } from "@apollo/client";
- import type { FragmentType, useFragment as useFragmentType } from "@/generated/graphql";
-
- const USER_CARD_FRAGMENT = gql`
- fragment UserCardFragment on User {
- id
- name
- avatar
- email
- }
- `;
-
- interface UserCardProps {
- userRef: FragmentType<typeof USER_CARD_FRAGMENT>;
- }
-
- function UserCard({ userRef }: UserCardProps) {
- // Reads fragment data from cache, updates automatically
- const { data: user, complete } = useFragment({
- fragment: USER_CARD_FRAGMENT,
- from: userRef,
- });
-
- if (!complete) {
- return <UserCardSkeleton />;
- }
-
- return (
- <div className="user-card">
- <img src={user.avatar} alt={user.name} />
- <h3>{user.name}</h3>
- <p>{user.email}</p>
- </div>
- );
- }
-
- export { UserCard, USER_CARD_FRAGMENT };
+ const { data: user, complete } = useFragment({
+ fragment: USER_CARD_FRAGMENT,
+ from: userRef,
+ });
+ if (!complete) return <Skeleton />;
```
- **Why good:** useFragment reads directly from cache without additional queries, `complete` flag indicates if all fragment fields are available, automatic re-render when fragment data updates in cache
+ **Why useful:** Reads directly from cache without additional queries, `complete` flag indicates if all fragment fields are available.
</patterns>
---
- <integration>
-
- ## Integration Guide
-
- **Works with:**
-
- - **GraphQL Codegen**: Generate TypeScript types from GraphQL schema and operations for full type safety
- - **React**: useQuery, useMutation, useSubscription hooks integrate with React component lifecycle
- - **WebSocket libraries (graphql-ws)**: Enable real-time subscriptions via WebSocket transport
- - **Error tracking solutions**: onError link integrates with error reporting services
-
- **Domain boundaries:**
-
- - **Server-side GraphQL schema**: Defer to backend skills for schema design, resolvers, and server setup
- - **REST APIs**: Use your REST data fetching solution instead - Apollo Client is for GraphQL only
- - **Global client state**: Use your client state management solution for complex non-GraphQL state; reactive variables work for simple cases
-
- </integration>
-
- ---
-
<version_migration>
## Apollo Client v4 Migration Notes
- **Apollo Client v4** (released September 2025) introduces significant breaking changes. If upgrading from v3, review:
-
- ### Import Path Changes (CRITICAL)
+ **Apollo Client v4** (released September 2025, latest v4.1.6) introduces significant breaking changes. A codemod handles most mechanical changes: `npx @apollo/client-codemod-migrate-3-to-4`
- ```typescript
- // v3 imports (DEPRECATED in v4)
- import { useQuery, useMutation, ApolloProvider } from "@apollo/client";
+ ### Breaking Changes Summary
- // v4 imports (REQUIRED)
- import { ApolloClient, InMemoryCache } from "@apollo/client";
- import { useQuery, useMutation, ApolloProvider } from "@apollo/client/react";
- ```
+ | Change | v3 | v4 |
+ | ----------------------------- | --------------------- | ---------------------------------------------------------- |
+ | React hook imports | `@apollo/client` | `@apollo/client/react` |
+ | Client `uri` option | Allowed directly | Must use explicit `HttpLink` |
+ | `name`/`version` | Top-level on client | `clientAwareness: { name, version }` |
+ | `notifyOnNetworkStatusChange` | Default `false` | Default `true` |
+ | Error classes | `ApolloError` | `CombinedGraphQLErrors`, `ServerError`, `ServerParseError` |
+ | Observable library | `zen-observable` | `rxjs` (peer dependency) |
+ | Link creation | `createHttpLink()` | `new HttpLink()` (class-based) |
+ | `from()`/`concat()`/`split()` | Standalone functions | `ApolloLink.from()` static methods |
+ | `connectToDevTools` | Client option | Removed (use browser extension) |
+ | Local resolvers | `resolvers` on client | Explicit `LocalState` class |
- ### New `dataState` Property (v4)
+ ### New: `dataState` Property (v4)
```typescript
- // v4: Use dataState for TypeScript-safe state checking
const { data, dataState } = useQuery(GET_USER);
-
- // dataState values: "empty" | "partial" | "streaming" | "complete"
+ // dataState: "empty" | "partial" | "streaming" | "complete"
if (dataState === "complete") {
// TypeScript knows data is fully populated
- return <UserCard user={data.user} />;
}
```
- ### Error Handling Changes (v4)
+ ### New: Error Type Guards (v4)
```typescript
- // v3: Single ApolloError class
- import { ApolloError } from "@apollo/client";
- if (error instanceof ApolloError) { ... }
+ import { CombinedGraphQLErrors, ServerError } from "@apollo/client";
- // v4: Specific error classes
- import { CombinedGraphQLErrors, ServerError, ServerParseError } from "@apollo/client";
- if (CombinedGraphQLErrors.is(error)) { ... }
- if (ServerError.is(error)) { ... }
+ if (CombinedGraphQLErrors.is(error)) {
+ error.errors.forEach(({ message }) => console.error(message));
+ }
+ if (ServerError.is(error)) {
+ console.error(`Server responded with ${error.statusCode}`);
+ }
```
- ### RxJS Dependency (v4)
+ See [Apollo Client 4 Migration Guide](https://www.apollographql.com/docs/react/migrating/apollo-client-4-migration) for complete details.
- ```bash
- # v4 requires rxjs as peer dependency
- npm install rxjs
- ```
+ </version_migration>
- ### Client Initialization (v4)
+ ---
- ```typescript
- // v3: uri option on ApolloClient (DEPRECATED)
- const client = new ApolloClient({
- uri: "https://api.example.com/graphql",
- name: "my-app",
- version: "1.0.0",
- cache: new InMemoryCache(),
- });
+ <red_flags>
- // v4: Explicit HttpLink + clientAwareness (REQUIRED)
- import { HttpLink } from "@apollo/client/link/http";
- const client = new ApolloClient({
- link: new HttpLink({ uri: "https://api.example.com/graphql" }),
- cache: new InMemoryCache(),
- clientAwareness: {
- name: "my-app",
- version: "1.0.0",
- },
- });
- ```
+ ## RED FLAGS
- ### notifyOnNetworkStatusChange Default (v4)
+ **High Priority Issues:**
- ```typescript
- // v3: Defaults to false
- useQuery(GET_USER); // notifyOnNetworkStatusChange: false
+ - **Manual GraphQL type definitions** - Use GraphQL Codegen; manual types drift from schema causing runtime errors
+ - **Missing `__typename` in optimistic responses** - Cache normalization fails silently
+ - **Missing `id` in query responses** - Apollo cannot normalize data without identifiers
+ - **Missing `keyArgs` in paginated type policies** - Different filters overwrite each other in cache
+ - **(v4) Importing React hooks from `@apollo/client`** - Must use `@apollo/client/react` in v4
- // v4: Defaults to true (BREAKING CHANGE)
- useQuery(GET_USER); // notifyOnNetworkStatusChange: true
+ **Medium Priority Issues:**
- // Explicitly set to false in v4 if you want v3 behavior
- useQuery(GET_USER, { notifyOnNetworkStatusChange: false });
- ```
+ - **Not using `errorPolicy: "all"`** - Partial data is often better UX than complete failure
+ - **`refetchQueries` for simple updates** - Direct cache updates with `cache.modify` are more efficient
+ - **`network-only` for all queries** - `cache-and-network` provides better UX (stale-while-revalidate)
+ - **Not typing `useQuery`/`useMutation` generics** - Loses type safety benefits
- **Codemod available:** `npx @apollo/client-codemod-migrate-3-to-4`
+ **Gotchas & Edge Cases:**
- See [Apollo Client 4 Migration Guide](https://www.apollographql.com/docs/react/migrating/apollo-client-4-migration) for complete details.
+ - `fetchMore` pagination requires type policy merge functions - without them, new data replaces old
+ - `cache.evict` must be followed by `cache.gc()` to clean up orphaned references
+ - `readField` in type policies is safer than direct property access (handles References)
+ - Optimistic responses are discarded automatically on error - no manual rollback needed
+ - `refetchQueries` runs after `update` callback, not before
+ - `pollInterval: 0` disables polling; omit the option entirely for no polling
+ - Type policies with `keyFields: false` embed objects in parent (no separate cache entry)
+ - Subscriptions require separate WebSocket link with `split` - queries/mutations stay on HTTP
+ - `useSuspenseQuery` has no `loading` state - it suspends; errors throw to Error Boundary
+ - `queryRef` from `useLoadableQuery` must be passed to `useReadQuery` inside a Suspense boundary
+ - (v4) `notifyOnNetworkStatusChange` defaults to `true` - may cause unexpected re-renders
+ - (v4) `rxjs` is a required peer dependency - must install explicitly
- </version_migration>
+ </red_flags>
---
<critical_reminders>
## CRITICAL REMINDERS
**(You MUST use GraphQL Codegen for type generation - NEVER write manual TypeScript types for GraphQL)**
**(You MUST include `__typename` and `id` in all optimistic responses for cache normalization)**
- **(You MUST configure type policies with appropriate `keyFields` for cache identification)**
+ **(You MUST configure type policies with appropriate `keyFields` for every entity type)**
**(You MUST use named constants for ALL timeout, retry, and polling values - NO magic numbers)**
**(You MUST use named exports only - NO default exports)**
**(For v4: You MUST import React hooks from `@apollo/client/react` - NOT from `@apollo/client`)**
**Failure to follow these rules will cause cache inconsistencies, type drift, and production bugs.**
</critical_reminders>