GraphQL Client Patterns for Production Apps
·APIScout Team
graphqlapollourqlapi integrationfrontend
GraphQL Client Patterns for Production Apps
GraphQL gives you exactly the data you need. But a production GraphQL client needs more than queries — it needs caching, error handling, optimistic updates, pagination, and offline support. Here are the patterns that work at scale.
Choosing a GraphQL Client
| Client | Bundle Size | Cache | Best For |
|---|---|---|---|
| Apollo Client | ~33KB | Normalized | Full-featured, enterprise |
| urql | ~8KB | Document/normalized | Lightweight, flexible |
| TanStack Query + graphql-request | ~12KB + 2KB | Query-based | Simple, REST-like DX |
| Relay | ~30KB | Normalized, compiler | Meta-scale apps |
| graphql-request | ~2KB | None | Simple scripts, SSR |
Quick Comparison
// Apollo Client
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: '123' },
});
// urql
const [result] = useQuery({ query: GET_USER, variables: { id: '123' } });
const { data, fetching, error } = result;
// TanStack Query + graphql-request
const { data, isLoading, error } = useQuery({
queryKey: ['user', '123'],
queryFn: () => graphqlClient.request(GET_USER, { id: '123' }),
});
Pattern 1: Type-Safe Queries with Codegen
Generate TypeScript types from your GraphQL schema:
# Install
npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
@graphql-codegen/typescript-operations @graphql-codegen/typed-document-node
# codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'https://api.example.com/graphql',
documents: 'src/**/*.graphql',
generates: {
'./src/generated/graphql.ts': {
plugins: [
'typescript',
'typescript-operations',
'typed-document-node',
],
},
},
};
export default config;
# src/queries/user.graphql
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
avatar
posts {
id
title
}
}
}
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
}
}
// Usage — fully type-safe, no `any`
import { GetUserDocument, UpdateUserDocument } from '@/generated/graphql';
const { data } = useQuery(GetUserDocument, { variables: { id: '123' } });
// data.user.name is typed as string
// data.user.posts[0].title is typed as string
// data.user.nonExistent → TypeScript error
Pattern 2: Error Handling
// Centralized error handling
function useGraphQL<TData, TVariables>(
document: TypedDocumentNode<TData, TVariables>,
variables?: TVariables
) {
const { data, loading, error } = useQuery(document, {
variables,
errorPolicy: 'all', // Return partial data with errors
});
// Categorize errors
if (error) {
const graphQLErrors = error.graphQLErrors || [];
const networkError = error.networkError;
// Network error (API unreachable)
if (networkError) {
return {
data: null,
error: { type: 'network', message: 'Unable to connect. Check your connection.' },
loading: false,
};
}
// Auth error
const authError = graphQLErrors.find(e =>
e.extensions?.code === 'UNAUTHENTICATED'
);
if (authError) {
// Redirect to login
router.push('/login');
return { data: null, error: { type: 'auth', message: 'Session expired' }, loading: false };
}
// Validation error
const validationError = graphQLErrors.find(e =>
e.extensions?.code === 'BAD_USER_INPUT'
);
if (validationError) {
return {
data: null,
error: {
type: 'validation',
message: validationError.message,
fields: validationError.extensions?.fields,
},
loading: false,
};
}
// Generic error
return {
data: null,
error: { type: 'unknown', message: 'Something went wrong' },
loading: false,
};
}
return { data, error: null, loading };
}
Pattern 3: Optimistic Updates
Update the UI immediately, then sync with the server:
const [updateTodo] = useMutation(UPDATE_TODO, {
// Optimistic response — show update immediately
optimisticResponse: {
updateTodo: {
__typename: 'Todo',
id: todoId,
text: newText,
completed: true,
},
},
// Update cache with the optimistic (then real) response
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: 'Todo', id: todoId }),
fields: {
text: () => data.updateTodo.text,
completed: () => data.updateTodo.completed,
},
});
},
// If the mutation fails, Apollo automatically reverts the optimistic update
onError(error) {
toast.error('Failed to update todo. Please try again.');
},
});
Pattern 4: Pagination
Cursor-Based (Relay-style)
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
node {
id
title
createdAt
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function PostList() {
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 20 },
});
const loadMore = () => {
fetchMore({
variables: {
first: 20,
after: data.posts.pageInfo.endCursor,
},
updateQuery: (prev, { fetchMoreResult }) => ({
posts: {
...fetchMoreResult.posts,
edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
},
}),
});
};
return (
<div>
{data?.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{data?.posts.pageInfo.hasNextPage && (
<button onClick={loadMore} disabled={loading}>
Load More
</button>
)}
</div>
);
}
Infinite Scroll
function useInfiniteGraphQL(query: DocumentNode, variables: any) {
const { data, loading, fetchMore } = useQuery(query, { variables });
const observerRef = useRef<IntersectionObserver>();
const loadMoreRef = useCallback((node: HTMLElement | null) => {
if (loading) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && data?.posts.pageInfo.hasNextPage) {
fetchMore({
variables: { after: data.posts.pageInfo.endCursor },
});
}
});
if (node) observerRef.current.observe(node);
}, [loading, data, fetchMore]);
return { data, loading, loadMoreRef };
}
// Usage
function PostFeed() {
const { data, loading, loadMoreRef } = useInfiniteGraphQL(GET_POSTS, { first: 20 });
return (
<div>
{data?.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
<div ref={loadMoreRef} /> {/* Trigger when visible */}
{loading && <Spinner />}
</div>
);
}
Pattern 5: Subscriptions (Real-Time)
// WebSocket-based real-time updates
const MESSAGES_SUBSCRIPTION = gql`
subscription OnNewMessage($channelId: ID!) {
messageCreated(channelId: $channelId) {
id
text
sender {
id
name
}
createdAt
}
}
`;
function ChatRoom({ channelId }: { channelId: string }) {
const { data: messages } = useQuery(GET_MESSAGES, {
variables: { channelId },
});
// Subscribe to new messages
useSubscription(MESSAGES_SUBSCRIPTION, {
variables: { channelId },
onData({ data }) {
// New message arrives — update cache
const newMessage = data.data.messageCreated;
// Apollo automatically updates if using cache policies
},
});
return (
<div>
{messages?.channel.messages.map(msg => (
<Message key={msg.id} message={msg} />
))}
</div>
);
}
Pattern 6: Fragment Colocation
Keep data requirements next to the component that uses them:
// UserCard.tsx — declares its own data needs
export const USER_CARD_FRAGMENT = gql`
fragment UserCardFields on User {
id
name
avatar
role
}
`;
function UserCard({ user }: { user: UserCardFieldsFragment }) {
return (
<div>
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<span>{user.role}</span>
</div>
);
}
// Parent page composes fragments
const GET_TEAM = gql`
${USER_CARD_FRAGMENT}
query GetTeam($teamId: ID!) {
team(id: $teamId) {
id
name
members {
...UserCardFields
}
}
}
`;
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| No codegen | Manual types, runtime errors | Set up graphql-codegen |
Over-fetching with * | Defeats GraphQL's purpose | Query only needed fields |
| No error categorization | Generic "something went wrong" | Handle auth, validation, network separately |
| Missing optimistic updates | UI feels slow | Add optimistic responses for mutations |
| N+1 queries on client | Many round-trips | Use fragments, batch queries |
| No pagination | Loading all data at once | Use cursor-based pagination |
Compare GraphQL APIs and clients on APIScout — find the best GraphQL endpoints, compare schema designs, and evaluate DX.