Skip to main content

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

ClientBundle SizeCacheBest For
Apollo Client~33KBNormalizedFull-featured, enterprise
urql~8KBDocument/normalizedLightweight, flexible
TanStack Query + graphql-request~12KB + 2KBQuery-basedSimple, REST-like DX
Relay~30KBNormalized, compilerMeta-scale apps
graphql-request~2KBNoneSimple 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

MistakeImpactFix
No codegenManual types, runtime errorsSet up graphql-codegen
Over-fetching with *Defeats GraphQL's purposeQuery only needed fields
No error categorizationGeneric "something went wrong"Handle auth, validation, network separately
Missing optimistic updatesUI feels slowAdd optimistic responses for mutations
N+1 queries on clientMany round-tripsUse fragments, batch queries
No paginationLoading all data at onceUse cursor-based pagination

Compare GraphQL APIs and clients on APIScout — find the best GraphQL endpoints, compare schema designs, and evaluate DX.

Comments