Skip to main content

GraphQL + Apollo Client Best Practices

Patterns et bonnes pratiques que j'utilise avec Apollo Client dans nos projets React.

Structure des requêtes

Co-localisation avec les composants

// components/UserProfile/UserProfile.tsx
import { gql, useQuery } from '@apollo/client';

// Définis la query à côté du composant qui l'utilise
const USER_PROFILE_QUERY = gql`
query UserProfile($userId: ID!) {
user(id: $userId) {
id
name
email
avatar
# Demande uniquement ce dont tu as besoin
}
}
`;

export function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useQuery(USER_PROFILE_QUERY, {
variables: { userId },
});

// ...
}

Fragments réutilisables

// fragments/UserFields.ts
import { gql } from '@apollo/client';

export const USER_BASIC_FIELDS = gql`
fragment UserBasicFields on User {
id
name
email
avatar
}
`;

export const USER_DETAILED_FIELDS = gql`
fragment UserDetailedFields on User {
...UserBasicFields
bio
location
createdAt
lastActive
}
${USER_BASIC_FIELDS}
`;

Utilisation :

import { USER_DETAILED_FIELDS } from './fragments/UserFields';

const USER_PROFILE_QUERY = gql`
query UserProfile($userId: ID!) {
user(id: $userId) {
...UserDetailedFields
}
}
${USER_DETAILED_FIELDS}
`;

Gestion du cache

Lecture du cache

// Lire une valeur du cache sans faire de requête
function useUserFromCache(userId: string) {
const client = useApolloClient();

const user = client.readFragment({
id: `User:${userId}`,
fragment: USER_BASIC_FIELDS,
});

return user;
}

Mise à jour optimiste

const [updateUser] = useMutation(UPDATE_USER_MUTATION, {
optimisticResponse: {
updateUser: {
__typename: 'User',
id: userId,
name: newName, // Nouvelle valeur
},
},
update(cache, { data }) {
// Mettre à jour le cache après la mutation
cache.modify({
id: cache.identify({ __typename: 'User', id: userId }),
fields: {
name() {
return data?.updateUser.name;
},
},
});
},
});

Cache eviction

const [deletePost] = useMutation(DELETE_POST_MUTATION, {
update(cache, { data }) {
const deletedPostId = data?.deletePost.id;

// Retirer l'objet du cache
cache.evict({ id: `Post:${deletedPostId}` });
cache.gc(); // Garbage collect
},
});

Error Handling

Hook personnalisé pour gérer les erreurs

import { ApolloError } from '@apollo/client';
import { useCallback } from 'react';

export function useGraphQLErrorHandler() {
return useCallback((error: ApolloError) => {
// Erreurs réseau
if (error.networkError) {
console.error('Network error:', error.networkError);
// Afficher un toast ou notification
return;
}

// Erreurs GraphQL
if (error.graphQLErrors) {
error.graphQLErrors.forEach(({ message, extensions }) => {
// Gérer selon le code d'erreur
switch (extensions?.code) {
case 'UNAUTHENTICATED':
// Rediriger vers login
break;
case 'FORBIDDEN':
// Afficher message d'accès refusé
break;
default:
console.error('GraphQL error:', message);
}
});
}
}, []);
}

Utilisation :

function MyComponent() {
const handleError = useGraphQLErrorHandler();

const { data, error } = useQuery(MY_QUERY, {
onError: handleError,
});

// ...
}

Pagination

Cursor-based pagination

const POSTS_QUERY = gql`
query Posts($after: String, $first: Int = 10) {
posts(after: $after, first: $first) {
edges {
cursor
node {
id
title
content
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;

function PostList() {
const { data, loading, fetchMore } = useQuery(POSTS_QUERY);

const loadMore = () => {
fetchMore({
variables: {
after: data?.posts.pageInfo.endCursor,
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;

return {
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}>Load more</button>
)}
</div>
);
}

Policies de cache

Configuration Apollo Client

import { InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Merge strategy pour les listes paginées
posts: {
keyArgs: ['filter'], // Cache séparé par filtre
merge(existing = { edges: [] }, incoming) {
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
User: {
fields: {
// Computed field local
fullName: {
read(_, { readField }) {
const firstName = readField('firstName');
const lastName = readField('lastName');
return `${firstName} ${lastName}`;
},
},
},
},
},
});

Local State Management

Client-only fields

const cache = new InMemoryCache({
typePolicies: {
User: {
fields: {
isSelected: {
read(existing = false) {
return existing;
},
},
},
},
},
});

// Mise à jour
cache.writeFragment({
id: `User:${userId}`,
fragment: gql`
fragment UserSelection on User {
isSelected
}
`,
data: {
isSelected: true,
},
});

Performance Tips

1. Utiliser useLazyQuery pour les requêtes à la demande

const [searchUsers, { data, loading }] = useLazyQuery(SEARCH_USERS_QUERY);

// Appeler uniquement quand nécessaire
const handleSearch = (term: string) => {
searchUsers({ variables: { term } });
};

2. Batching des requêtes

import { BatchHttpLink } from '@apollo/client/link/batch-http';

const link = new BatchHttpLink({
uri: '/graphql',
batchMax: 10, // Max 10 requêtes par batch
batchInterval: 20, // Attendre 20ms avant d'envoyer
});

3. Skip les requêtes conditionnellement

const { data } = useQuery(USER_QUERY, {
variables: { userId },
skip: !userId, // Ne pas exécuter si pas d'userId
});

4. Utiliser fetchPolicy intelligemment

// Cache-first (défaut) - bon pour les données statiques
useQuery(QUERY, { fetchPolicy: 'cache-first' });

// Network-only - pour les données temps réel
useQuery(QUERY, { fetchPolicy: 'network-only' });

// Cache-and-network - meilleur UX
useQuery(QUERY, { fetchPolicy: 'cache-and-network' });

Testing

Mock Apollo Provider

import { MockedProvider } from '@apollo/client/testing';

const mocks = [
{
request: {
query: USER_QUERY,
variables: { userId: '1' },
},
result: {
data: {
user: {
id: '1',
name: 'John Doe',
email: 'john@example.com',
},
},
},
},
];

test('renders user profile', async () => {
render(
<MockedProvider mocks={mocks}>
<UserProfile userId="1" />
</MockedProvider>
);

await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});

Checklist de bonnes pratiques

  • ✅ Utiliser des fragments pour réutiliser les champs
  • ✅ Co-localiser les queries avec les composants
  • ✅ Gérer les erreurs de manière centralisée
  • ✅ Optimiser les updates de cache avec optimisticResponse
  • ✅ Utiliser fetchPolicy appropriée selon le cas
  • ✅ Typer les queries/mutations avec TypeScript (via codegen)
  • ✅ Éviter les over-fetching (ne demander que ce dont on a besoin)
  • ✅ Implémenter la pagination pour les listes longues
  • ✅ Nettoyer le cache avec evict quand nécessaire

Ressources