Table of Contents
Introduction
GraphQL a revolutionne la facon dont nous interagissons avec les APIs. Contrairement a REST ou chaque endpoint renvoie une structure de donnees fixe, GraphQL permet aux clients de demander exactement les donnees dont ils ont besoin, ni plus, ni moins. Cette flexibilite, combinee avec la puissance d’Apollo Client dans Vue.js, offre une experience de developpement exceptionnelle.
Dans cet article complet, nous allons explorer en profondeur l’integration d’Apollo Client avec Vue.js et TypeScript. Nous couvrirons tous les aspects essentiels : de l’installation a la gestion avancee du cache, en passant par les mutations, les subscriptions temps reel, et les meilleures pratiques pour construire des applications robustes et performantes.
GraphQL vs REST : Pourquoi choisir GraphQL ?
Avant de plonger dans Apollo Client, comprenons pourquoi GraphQL est devenu si populaire :
| Aspect | REST | GraphQL |
|---|---|---|
| Recuperation des donnees | Endpoints multiples, over-fetching frequent | Une seule requete, donnees exactes |
| Typage | Documentation externe (OpenAPI) | Schema auto-documente et type |
| Versioning | URLs versionnees (/v1/, /v2/) | Evolution du schema sans breaking changes |
| Performance reseau | N+1 requetes possibles | Une requete = une reponse |
| Experience developpeur | Variable selon l’API | Outils puissants (GraphiQL, Apollo DevTools) |
GraphQL resout le probleme classique du “over-fetching” (recevoir trop de donnees) et du “under-fetching” (devoir faire plusieurs requetes). C’est particulierement precieux pour les applications mobiles ou la bande passante est limitee.
Installation et Configuration d’Apollo Client
Installation des dependances
Pour commencer avec Apollo Client dans Vue.js, installez les packages necessaires :
# Installation avec npm
npm install @apollo/client @vue/apollo-composable graphql graphql-tag
# Ou avec yarn
yarn add @apollo/client @vue/apollo-composable graphql graphql-tag
# Ou avec pnpm
pnpm add @apollo/client @vue/apollo-composable graphql graphql-tag
Pour le support TypeScript complet, ajoutez egalement :
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node
Configuration de base
Creez un fichier apollo.ts pour configurer votre client Apollo :
// src/apollo.ts
import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloLink,
from,
} from '@apollo/client/core';
import { onError } from '@apollo/client/link/error';
import { provideApolloClient } from '@vue/apollo-composable';
// Configuration du lien HTTP
const httpLink = createHttpLink({
uri: import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
credentials: 'include', // Pour les cookies d'authentification
});
// Middleware pour ajouter le token d'authentification
const authLink = new ApolloLink((operation, forward) => {
const token = localStorage.getItem('auth_token');
operation.setContext(({ headers = {} }) => ({
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
}));
return forward(operation);
});
// Gestion globale des erreurs
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
console.error(
`[GraphQL Error]: Message: ${message}, Location: ${locations}, Path: ${path}`
);
// Gerer les erreurs d'authentification
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
});
}
if (networkError) {
console.error(`[Network Error]: ${networkError.message}`);
// Afficher une notification a l'utilisateur
}
});
// Configuration du cache avec type policies
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Pagination avec merge personnalise
listings: {
keyArgs: ['filter', 'sortBy'],
merge(existing = [], incoming, { args }) {
if (args?.offset === 0) {
return incoming;
}
return [...existing, ...incoming];
},
},
},
},
Listing: {
// Identifiant unique pour le cache
keyFields: ['id'],
},
User: {
keyFields: ['id'],
fields: {
fullName: {
read(_, { readField }) {
const firstName = readField<string>('firstName');
const lastName = readField<string>('lastName');
return `${firstName} ${lastName}`;
},
},
},
},
},
});
// Creation du client Apollo
export const apolloClient = new ApolloClient({
link: from([errorLink, authLink, httpLink]),
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
errorPolicy: 'all',
},
query: {
fetchPolicy: 'cache-first',
errorPolicy: 'all',
},
mutate: {
errorPolicy: 'all',
},
},
connectToDevTools: import.meta.env.DEV,
});
// Fournir le client globalement
provideApolloClient(apolloClient);
export default apolloClient;
Integration dans main.ts
// src/main.ts
import { createApp, provide, h } from 'vue';
import { DefaultApolloClient } from '@vue/apollo-composable';
import App from './App.vue';
import { apolloClient } from './apollo';
const app = createApp({
setup() {
provide(DefaultApolloClient, apolloClient);
},
render: () => h(App),
});
app.mount('#app');
useQuery() avec Typage TypeScript
Le composable useQuery est la methode principale pour recuperer des donnees avec Apollo Client dans Vue.js.
Definition des types et requetes
// src/graphql/types.ts
export interface Listing {
id: string;
title: string;
description: string;
price: number;
category: string;
images: string[];
owner: User;
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
firstName: string;
lastName: string;
email: string;
avatar?: string;
}
export interface ListingsQueryVariables {
filter?: {
category?: string;
minPrice?: number;
maxPrice?: number;
search?: string;
};
sortBy?: 'price' | 'createdAt' | 'title';
sortOrder?: 'ASC' | 'DESC';
limit?: number;
offset?: number;
}
export interface ListingsQueryResult {
listings: Listing[];
totalCount: number;
}
// src/graphql/queries.ts
import gql from 'graphql-tag';
export const LISTINGS_QUERY = gql`
query GetListings(
$filter: ListingFilterInput
$sortBy: ListingSortField
$sortOrder: SortOrder
$limit: Int
$offset: Int
) {
listings(
filter: $filter
sortBy: $sortBy
sortOrder: $sortOrder
limit: $limit
offset: $offset
) {
id
title
description
price
category
images
owner {
id
firstName
lastName
avatar
}
createdAt
updatedAt
}
listingsCount(filter: $filter)
}
`;
export const LISTING_DETAIL_QUERY = gql`
query GetListing($id: ID!) {
listing(id: $id) {
id
title
description
price
category
images
owner {
id
firstName
lastName
email
avatar
}
createdAt
updatedAt
}
}
`;
Utilisation dans un composant Vue
<script setup lang="ts">
// src/components/ListingsList.vue
import { computed, ref, watch } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { LISTINGS_QUERY } from '@/graphql/queries';
import type { Listing, ListingsQueryVariables, ListingsQueryResult } from '@/graphql/types';
// Props du composant
const props = defineProps<{
category?: string;
initialLimit?: number;
}>();
// Variables reactives pour les filtres
const searchTerm = ref('');
const priceRange = ref({ min: 0, max: 10000 });
const sortBy = ref<'price' | 'createdAt' | 'title'>('createdAt');
const sortOrder = ref<'ASC' | 'DESC'>('DESC');
const currentPage = ref(0);
const pageSize = props.initialLimit || 10;
// Variables de requete calculees
const queryVariables = computed<ListingsQueryVariables>(() => ({
filter: {
category: props.category,
search: searchTerm.value || undefined,
minPrice: priceRange.value.min || undefined,
maxPrice: priceRange.value.max < 10000 ? priceRange.value.max : undefined,
},
sortBy: sortBy.value,
sortOrder: sortOrder.value,
limit: pageSize,
offset: currentPage.value * pageSize,
}));
// Utilisation de useQuery avec typage complet
const {
result,
loading,
error,
refetch,
fetchMore,
onResult,
onError,
} = useQuery<ListingsQueryResult, ListingsQueryVariables>(
LISTINGS_QUERY,
queryVariables,
{
// Options de la requete
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
// Activer la requete uniquement si necessaire
enabled: computed(() => !props.category || props.category.length > 0),
}
);
// Donnees calculees avec valeurs par defaut
const listings = computed<Listing[]>(() => result.value?.listings ?? []);
const totalCount = computed(() => result.value?.listingsCount ?? 0);
const hasMore = computed(() => listings.value.length < totalCount.value);
// Callbacks pour les evenements
onResult((queryResult) => {
console.log('Donnees recues:', queryResult.data);
if (queryResult.networkStatus === 7) {
// Requete terminee avec succes
}
});
onError((queryError) => {
console.error('Erreur de requete:', queryError.message);
// Afficher une notification d'erreur
});
// Charger plus de resultats (pagination infinite)
const loadMore = async () => {
if (!hasMore.value || loading.value) return;
await fetchMore({
variables: {
offset: listings.value.length,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
return {
...fetchMoreResult,
listings: [
...previousResult.listings,
...fetchMoreResult.listings,
],
};
},
});
};
// Rafraichir les donnees
const refresh = () => {
currentPage.value = 0;
refetch();
};
// Surveiller les changements de filtres
watch([searchTerm, priceRange, sortBy, sortOrder], () => {
currentPage.value = 0;
});
</script>
<template>
<div class="listings-container">
<!-- Filtres -->
<div class="filters">
<input
v-model="searchTerm"
type="search"
placeholder="Rechercher..."
class="search-input"
/>
<select v-model="sortBy" class="sort-select">
<option value="createdAt">Date</option>
<option value="price">Prix</option>
<option value="title">Titre</option>
</select>
<button @click="sortOrder = sortOrder === 'ASC' ? 'DESC' : 'ASC'">
{{ sortOrder === 'ASC' ? 'Croissant' : 'Decroissant' }}
</button>
<button @click="refresh" :disabled="loading">
Rafraichir
</button>
</div>
<!-- Etat de chargement -->
<div v-if="loading && !listings.length" class="loading">
Chargement des annonces...
</div>
<!-- Erreur -->
<div v-else-if="error" class="error">
<p>Une erreur est survenue : {{ error.message }}</p>
<button @click="refetch">Reessayer</button>
</div>
<!-- Liste des annonces -->
<template v-else>
<div class="listings-grid">
<ListingCard
v-for="listing in listings"
:key="listing.id"
:listing="listing"
/>
</div>
<!-- Pagination -->
<div v-if="hasMore" class="load-more">
<button @click="loadMore" :disabled="loading">
{{ loading ? 'Chargement...' : 'Charger plus' }}
</button>
<p class="count">
{{ listings.length }} sur {{ totalCount }} annonces
</p>
</div>
</template>
</div>
</template>
useMutation() avec exemples detailles
Les mutations permettent de modifier les donnees sur le serveur. useMutation offre un controle complet sur le cycle de vie de la mutation.
Definition des mutations
// src/graphql/mutations.ts
import gql from 'graphql-tag';
export const CREATE_LISTING_MUTATION = gql`
mutation CreateListing($input: CreateListingInput!) {
createListing(input: $input) {
id
title
description
price
category
images
owner {
id
firstName
lastName
}
createdAt
}
}
`;
export const UPDATE_LISTING_MUTATION = gql`
mutation UpdateListing($id: ID!, $input: UpdateListingInput!) {
updateListing(id: $id, input: $input) {
id
title
description
price
category
images
updatedAt
}
}
`;
export const DELETE_LISTING_MUTATION = gql`
mutation DeleteListing($id: ID!) {
deleteListing(id: $id) {
id
title
}
}
`;
Composant de formulaire avec mutation
<script setup lang="ts">
// src/components/ListingForm.vue
import { ref, reactive, computed } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useRouter } from 'vue-router';
import {
CREATE_LISTING_MUTATION,
UPDATE_LISTING_MUTATION,
} from '@/graphql/mutations';
import { LISTINGS_QUERY, LISTING_DETAIL_QUERY } from '@/graphql/queries';
import type { Listing } from '@/graphql/types';
// Types pour les mutations
interface CreateListingInput {
title: string;
description: string;
price: number;
category: string;
images: string[];
}
interface CreateListingResult {
createListing: Listing;
}
interface UpdateListingResult {
updateListing: Listing;
}
// Props
const props = defineProps<{
listingId?: string; // Si present, mode edition
}>();
const emit = defineEmits<{
(e: 'success', listing: Listing): void;
(e: 'cancel'): void;
}>();
const router = useRouter();
// Mode edition ou creation
const isEditMode = computed(() => !!props.listingId);
// Formulaire reactif
const form = reactive<CreateListingInput>({
title: '',
description: '',
price: 0,
category: '',
images: [],
});
// Charger les donnees existantes en mode edition
const { onResult: onListingLoaded } = useQuery(
LISTING_DETAIL_QUERY,
{ id: props.listingId },
{ enabled: isEditMode }
);
onListingLoaded((result) => {
if (result.data?.listing) {
const listing = result.data.listing;
form.title = listing.title;
form.description = listing.description;
form.price = listing.price;
form.category = listing.category;
form.images = [...listing.images];
}
});
// Mutation de creation
const {
mutate: createListing,
loading: createLoading,
error: createError,
onDone: onCreateDone,
onError: onCreateError,
} = useMutation<CreateListingResult, { input: CreateListingInput }>(
CREATE_LISTING_MUTATION,
{
// Mise a jour optimiste du cache
optimisticResponse: {
createListing: {
__typename: 'Listing',
id: 'temp-id-' + Date.now(),
title: form.title,
description: form.description,
price: form.price,
category: form.category,
images: form.images,
owner: {
__typename: 'User',
id: 'current-user',
firstName: 'Moi',
lastName: '',
},
createdAt: new Date().toISOString(),
},
},
// Mise a jour manuelle du cache
update: (cache, { data }) => {
if (!data?.createListing) return;
// Lire le cache existant
const existingData = cache.readQuery({
query: LISTINGS_QUERY,
variables: { limit: 10, offset: 0 },
});
if (existingData) {
// Ajouter le nouvel element au debut
cache.writeQuery({
query: LISTINGS_QUERY,
variables: { limit: 10, offset: 0 },
data: {
...existingData,
listings: [data.createListing, ...existingData.listings],
listingsCount: existingData.listingsCount + 1,
},
});
}
},
// Refetch des requetes liees
refetchQueries: ['GetListings'],
// Attendre les refetch avant de resoudre
awaitRefetchQueries: true,
}
);
// Mutation de mise a jour
const {
mutate: updateListing,
loading: updateLoading,
error: updateError,
onDone: onUpdateDone,
onError: onUpdateError,
} = useMutation<UpdateListingResult>(UPDATE_LISTING_MUTATION, {
optimisticResponse: ({ id, input }) => ({
updateListing: {
__typename: 'Listing',
id,
...input,
updatedAt: new Date().toISOString(),
},
}),
});
// Gestion du loading et des erreurs combines
const loading = computed(() => createLoading.value || updateLoading.value);
const error = computed(() => createError.value || updateError.value);
// Callbacks de succes
onCreateDone((result) => {
if (result.data?.createListing) {
emit('success', result.data.createListing);
router.push(`/listings/${result.data.createListing.id}`);
}
});
onUpdateDone((result) => {
if (result.data?.updateListing) {
emit('success', result.data.updateListing);
}
});
// Callbacks d'erreur
onCreateError((err) => {
console.error('Erreur de creation:', err);
});
onUpdateError((err) => {
console.error('Erreur de mise a jour:', err);
});
// Validation du formulaire
const isValid = computed(() => {
return (
form.title.length >= 3 &&
form.description.length >= 10 &&
form.price > 0 &&
form.category !== ''
);
});
// Soumission du formulaire
const handleSubmit = async () => {
if (!isValid.value) return;
try {
if (isEditMode.value) {
await updateListing({
id: props.listingId,
input: { ...form },
});
} else {
await createListing({
input: { ...form },
});
}
} catch (err) {
// L'erreur est deja geree par onError
}
};
// Ajout d'une image
const addImage = (url: string) => {
if (url && !form.images.includes(url)) {
form.images.push(url);
}
};
// Suppression d'une image
const removeImage = (index: number) => {
form.images.splice(index, 1);
};
</script>
<template>
<form @submit.prevent="handleSubmit" class="listing-form">
<h2>{{ isEditMode ? 'Modifier l\'annonce' : 'Nouvelle annonce' }}</h2>
<!-- Erreur -->
<div v-if="error" class="error-message">
{{ error.message }}
</div>
<!-- Champs du formulaire -->
<div class="form-group">
<label for="title">Titre</label>
<input
id="title"
v-model="form.title"
type="text"
required
minlength="3"
placeholder="Titre de l'annonce"
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
required
minlength="10"
rows="5"
placeholder="Decrivez votre article..."
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="price">Prix (EUR)</label>
<input
id="price"
v-model.number="form.price"
type="number"
min="0"
step="0.01"
required
/>
</div>
<div class="form-group">
<label for="category">Categorie</label>
<select id="category" v-model="form.category" required>
<option value="">Selectionnez...</option>
<option value="electronics">Electronique</option>
<option value="vehicles">Vehicules</option>
<option value="furniture">Mobilier</option>
<option value="clothing">Vetements</option>
</select>
</div>
</div>
<!-- Images -->
<div class="form-group">
<label>Images</label>
<div class="images-list">
<div
v-for="(image, index) in form.images"
:key="index"
class="image-preview"
>
<img :src="image" :alt="`Image ${index + 1}`" />
<button type="button" @click="removeImage(index)">X</button>
</div>
</div>
<input
type="url"
placeholder="URL de l'image"
@keyup.enter="addImage(($event.target as HTMLInputElement).value)"
/>
</div>
<!-- Actions -->
<div class="form-actions">
<button type="button" @click="emit('cancel')">
Annuler
</button>
<button
type="submit"
:disabled="!isValid || loading"
class="primary"
>
{{ loading ? 'En cours...' : (isEditMode ? 'Modifier' : 'Creer') }}
</button>
</div>
</form>
</template>
Gestion du cache Apollo
Le cache est l’une des fonctionnalites les plus puissantes d’Apollo Client. Comprendre comment le manipuler est essentiel pour creer des applications performantes.
Strategies de cache (Fetch Policies)
| Policy | Description | Cas d’utilisation |
|---|---|---|
cache-first | Cache d’abord, reseau si absent | Donnees stables (profil utilisateur) |
cache-and-network | Cache immediat + mise a jour reseau | Listes frequemment mises a jour |
network-only | Toujours depuis le reseau | Donnees critiques (paiements) |
cache-only | Uniquement depuis le cache | Donnees pre-chargees |
no-cache | Pas de cache, pas de stockage | Donnees sensibles |
standby | Ne pas executer automatiquement | Requetes conditionnelles |
Manipulation directe du cache
// src/composables/useListingCache.ts
import { useApolloClient } from '@vue/apollo-composable';
import { LISTINGS_QUERY, LISTING_DETAIL_QUERY } from '@/graphql/queries';
import type { Listing } from '@/graphql/types';
export function useListingCache() {
const { resolveClient } = useApolloClient();
// Lire une entite du cache
const readListingFromCache = (id: string): Listing | null => {
const client = resolveClient();
// Methode 1: readFragment
return client.cache.readFragment({
id: client.cache.identify({ __typename: 'Listing', id }),
fragment: gql`
fragment ListingFields on Listing {
id
title
description
price
category
images
}
`,
});
};
// Ecrire une entite dans le cache
const writeListingToCache = (listing: Partial<Listing> & { id: string }) => {
const client = resolveClient();
client.cache.writeFragment({
id: client.cache.identify({ __typename: 'Listing', id: listing.id }),
fragment: gql`
fragment ListingUpdate on Listing {
id
title
description
price
category
}
`,
data: listing,
});
};
// Modifier une entite dans le cache
const modifyListingInCache = (
id: string,
modifications: Partial<Omit<Listing, 'id'>>
) => {
const client = resolveClient();
client.cache.modify({
id: client.cache.identify({ __typename: 'Listing', id }),
fields: {
title: (currentTitle) => modifications.title ?? currentTitle,
price: (currentPrice) => modifications.price ?? currentPrice,
// Pour les champs complexes
images: (currentImages, { toReference }) => {
return modifications.images ?? currentImages;
},
},
});
};
// Supprimer une entite du cache
const evictListingFromCache = (id: string) => {
const client = resolveClient();
// Eviction complete de l'entite
client.cache.evict({
id: client.cache.identify({ __typename: 'Listing', id }),
});
// Nettoyer les references orphelines
client.cache.gc();
};
// Ajouter une entite a une liste dans le cache
const addListingToList = (listing: Listing, variables = {}) => {
const client = resolveClient();
const existingData = client.cache.readQuery({
query: LISTINGS_QUERY,
variables,
});
if (existingData) {
client.cache.writeQuery({
query: LISTINGS_QUERY,
variables,
data: {
...existingData,
listings: [listing, ...existingData.listings],
listingsCount: existingData.listingsCount + 1,
},
});
}
};
// Retirer une entite d'une liste dans le cache
const removeListingFromList = (id: string, variables = {}) => {
const client = resolveClient();
const existingData = client.cache.readQuery({
query: LISTINGS_QUERY,
variables,
});
if (existingData) {
client.cache.writeQuery({
query: LISTINGS_QUERY,
variables,
data: {
...existingData,
listings: existingData.listings.filter(
(listing: Listing) => listing.id !== id
),
listingsCount: existingData.listingsCount - 1,
},
});
}
};
// Reinitialiser completement le cache
const resetCache = async () => {
const client = resolveClient();
await client.resetStore();
};
// Vider le cache sans refetch
const clearCache = () => {
const client = resolveClient();
client.cache.reset();
};
return {
readListingFromCache,
writeListingToCache,
modifyListingInCache,
evictListingFromCache,
addListingToList,
removeListingFromList,
resetCache,
clearCache,
};
}
useLazyQuery() pour les requetes a la demande
Contrairement a useQuery qui s’execute immediatement, useLazyQuery permet de declencher la requete manuellement.
<script setup lang="ts">
// src/components/ListingSearch.vue
import { ref, computed } from 'vue';
import { useLazyQuery } from '@vue/apollo-composable';
import gql from 'graphql-tag';
// Requete de recherche
const SEARCH_LISTINGS = gql`
query SearchListings($query: String!, $limit: Int) {
searchListings(query: $query, limit: $limit) {
id
title
description
price
images
relevanceScore
}
}
`;
interface SearchResult {
id: string;
title: string;
description: string;
price: number;
images: string[];
relevanceScore: number;
}
const searchQuery = ref('');
const minChars = 3;
// useLazyQuery ne s'execute pas automatiquement
const {
result,
loading,
error,
load: executeSearch, // Fonction pour declencher la recherche
refetch,
onResult,
onError,
} = useLazyQuery<{ searchListings: SearchResult[] }>(
SEARCH_LISTINGS,
{
query: searchQuery,
limit: 10,
},
{
// Options
fetchPolicy: 'network-only', // Toujours des resultats frais
debounce: 300, // Debounce integre
}
);
const results = computed(() => result.value?.searchListings ?? []);
// Callback sur resultat
onResult((queryResult) => {
if (queryResult.data?.searchListings) {
console.log(`${queryResult.data.searchListings.length} resultats trouves`);
}
});
// Gestion de la recherche
const handleSearch = async () => {
if (searchQuery.value.length < minChars) {
return;
}
// Premiere execution ou refetch
if (result.value) {
await refetch({ query: searchQuery.value });
} else {
await executeSearch(SEARCH_LISTINGS, {
query: searchQuery.value,
limit: 10,
});
}
};
// Recherche avec debounce
let debounceTimer: NodeJS.Timeout;
const debouncedSearch = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(handleSearch, 300);
};
</script>
<template>
<div class="search-container">
<div class="search-input-wrapper">
<input
v-model="searchQuery"
type="search"
placeholder="Rechercher des annonces..."
@input="debouncedSearch"
@keyup.enter="handleSearch"
/>
<button
@click="handleSearch"
:disabled="searchQuery.length < minChars || loading"
>
{{ loading ? 'Recherche...' : 'Rechercher' }}
</button>
</div>
<p v-if="searchQuery.length > 0 && searchQuery.length < minChars" class="hint">
Entrez au moins {{ minChars }} caracteres
</p>
<div v-if="error" class="error">
Erreur: {{ error.message }}
</div>
<div v-else-if="results.length > 0" class="results">
<h3>{{ results.length }} resultat(s)</h3>
<ul>
<li
v-for="item in results"
:key="item.id"
class="result-item"
>
<img :src="item.images[0]" :alt="item.title" />
<div class="result-info">
<h4>{{ item.title }}</h4>
<p>{{ item.description.substring(0, 100) }}...</p>
<span class="price">{{ item.price }} EUR</span>
</div>
</li>
</ul>
</div>
<p v-else-if="result && !loading" class="no-results">
Aucun resultat pour "{{ searchQuery }}"
</p>
</div>
</template>
Subscriptions en temps reel
Les subscriptions GraphQL permettent de recevoir des mises a jour en temps reel via WebSockets.
Configuration des subscriptions
// src/apollo-subscriptions.ts
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client/core';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
// Lien HTTP pour queries et mutations
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql',
});
// Lien WebSocket pour subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: () => ({
authToken: localStorage.getItem('auth_token'),
}),
// Reconnexion automatique
retryAttempts: 5,
shouldRetry: () => true,
on: {
connected: () => console.log('WebSocket connecte'),
closed: () => console.log('WebSocket deconnecte'),
error: (error) => console.error('Erreur WebSocket:', error),
},
})
);
// Split: WebSocket pour subscriptions, HTTP pour le reste
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
export const apolloClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
Utilisation des subscriptions
<script setup lang="ts">
// src/components/ListingUpdates.vue
import { ref, onUnmounted } from 'vue';
import { useSubscription, useQuery } from '@vue/apollo-composable';
import gql from 'graphql-tag';
import { LISTINGS_QUERY } from '@/graphql/queries';
// Subscription pour les nouveaux listings
const LISTING_ADDED_SUBSCRIPTION = gql`
subscription OnListingAdded($category: String) {
listingAdded(category: $category) {
id
title
description
price
category
images
owner {
id
firstName
}
createdAt
}
}
`;
// Subscription pour les mises a jour
const LISTING_UPDATED_SUBSCRIPTION = gql`
subscription OnListingUpdated($id: ID!) {
listingUpdated(id: $id) {
id
title
description
price
updatedAt
}
}
`;
// Subscription pour les suppressions
const LISTING_DELETED_SUBSCRIPTION = gql`
subscription OnListingDeleted {
listingDeleted {
id
}
}
`;
const props = defineProps<{
category?: string;
watchListingId?: string;
}>();
// Notifications de nouveaux listings
const newListings = ref<any[]>([]);
// Subscription pour les nouveaux listings
const {
result: addedResult,
loading: addedLoading,
error: addedError,
onResult: onListingAdded,
} = useSubscription(
LISTING_ADDED_SUBSCRIPTION,
{ category: props.category },
{ enabled: true }
);
onListingAdded((result) => {
if (result.data?.listingAdded) {
newListings.value.unshift(result.data.listingAdded);
// Notification visuelle
showNotification(`Nouvelle annonce: ${result.data.listingAdded.title}`);
}
});
// Subscription pour les mises a jour d'un listing specifique
const {
onResult: onListingUpdated,
} = useSubscription(
LISTING_UPDATED_SUBSCRIPTION,
() => ({ id: props.watchListingId }),
{
enabled: () => !!props.watchListingId,
}
);
onListingUpdated((result) => {
if (result.data?.listingUpdated) {
showNotification(`Annonce mise a jour: ${result.data.listingUpdated.title}`);
}
});
// Subscription pour les suppressions avec mise a jour du cache
const { resolveClient } = useApolloClient();
const {
onResult: onListingDeleted,
} = useSubscription(LISTING_DELETED_SUBSCRIPTION);
onListingDeleted((result) => {
if (result.data?.listingDeleted) {
const client = resolveClient();
const deletedId = result.data.listingDeleted.id;
// Eviction du cache
client.cache.evict({
id: client.cache.identify({ __typename: 'Listing', id: deletedId }),
});
client.cache.gc();
showNotification('Une annonce a ete supprimee');
}
});
// Helper pour les notifications
const showNotification = (message: string) => {
// Implementation de notification (toast, etc.)
console.log(message);
};
// Nettoyer les nouvelles annonces
const clearNewListings = () => {
newListings.value = [];
};
</script>
<template>
<div class="real-time-updates">
<!-- Indicateur de connexion -->
<div v-if="addedLoading" class="status connecting">
Connexion en temps reel...
</div>
<div v-else-if="addedError" class="status error">
Erreur de connexion: {{ addedError.message }}
</div>
<div v-else class="status connected">
Mises a jour en temps reel actives
</div>
<!-- Nouvelles annonces -->
<div v-if="newListings.length > 0" class="new-listings-banner">
<p>{{ newListings.length }} nouvelle(s) annonce(s)</p>
<button @click="clearNewListings">Voir</button>
</div>
<!-- Liste des nouvelles annonces -->
<transition-group name="slide" tag="div" class="new-listings-list">
<div
v-for="listing in newListings"
:key="listing.id"
class="new-listing-item"
>
<h4>{{ listing.title }}</h4>
<p>{{ listing.price }} EUR</p>
</div>
</transition-group>
</div>
</template>
Gestion des erreurs
Une gestion robuste des erreurs est essentielle pour une bonne experience utilisateur.
Configuration globale des erreurs
// src/composables/useErrorHandling.ts
import { ref, computed } from 'vue';
import { ApolloError } from '@apollo/client/core';
export interface GraphQLErrorDetail {
message: string;
code?: string;
path?: string[];
field?: string;
}
export function useErrorHandling() {
const errors = ref<GraphQLErrorDetail[]>([]);
const networkError = ref<string | null>(null);
const hasErrors = computed(() => errors.value.length > 0 || !!networkError.value);
const handleApolloError = (error: ApolloError) => {
errors.value = [];
networkError.value = null;
// Erreurs GraphQL (erreurs metier)
if (error.graphQLErrors?.length) {
errors.value = error.graphQLErrors.map((err) => ({
message: err.message,
code: err.extensions?.code as string,
path: err.path as string[],
field: err.extensions?.field as string,
}));
}
// Erreur reseau
if (error.networkError) {
networkError.value = 'Erreur de connexion. Verifiez votre connexion internet.';
}
return { errors: errors.value, networkError: networkError.value };
};
const getErrorForField = (fieldName: string): string | null => {
const fieldError = errors.value.find((e) => e.field === fieldName);
return fieldError?.message ?? null;
};
const clearErrors = () => {
errors.value = [];
networkError.value = null;
};
// Messages d'erreur conviviaux
const getReadableError = (code: string): string => {
const errorMessages: Record<string, string> = {
UNAUTHENTICATED: 'Vous devez etre connecte pour effectuer cette action.',
FORBIDDEN: 'Vous n\'avez pas les droits pour cette action.',
NOT_FOUND: 'La ressource demandee n\'existe pas.',
VALIDATION_ERROR: 'Les donnees soumises sont invalides.',
INTERNAL_SERVER_ERROR: 'Une erreur serveur est survenue. Reessayez plus tard.',
BAD_USER_INPUT: 'Les informations fournies sont incorrectes.',
};
return errorMessages[code] ?? 'Une erreur inattendue est survenue.';
};
return {
errors,
networkError,
hasErrors,
handleApolloError,
getErrorForField,
clearErrors,
getReadableError,
};
}
Utilisation avec errorPolicy
<script setup lang="ts">
// src/components/SafeListingFetch.vue
import { computed } from 'vue';
import { useQuery, useMutation } from '@vue/apollo-composable';
import { useErrorHandling } from '@/composables/useErrorHandling';
import { LISTINGS_QUERY } from '@/graphql/queries';
import { DELETE_LISTING_MUTATION } from '@/graphql/mutations';
const {
errors,
networkError,
hasErrors,
handleApolloError,
clearErrors,
getReadableError,
} = useErrorHandling();
// Query avec gestion d'erreur avancee
const {
result,
loading,
error: queryError,
onError: onQueryError,
} = useQuery(LISTINGS_QUERY, {}, {
// 'all' permet de recevoir donnees partielles meme avec erreurs
errorPolicy: 'all',
// 'ignore' ignore les erreurs (non recommande)
// 'none' arrete sur la premiere erreur (default)
});
onQueryError((error) => {
handleApolloError(error);
});
// Mutation avec gestion d'erreur
const {
mutate: deleteListing,
onError: onMutationError,
} = useMutation(DELETE_LISTING_MUTATION, {
errorPolicy: 'all',
});
onMutationError((error) => {
const { errors: mutationErrors } = handleApolloError(error);
// Traitement specifique selon le code d'erreur
mutationErrors.forEach((err) => {
if (err.code === 'FORBIDDEN') {
// Rediriger vers la page de connexion
} else if (err.code === 'NOT_FOUND') {
// L'element a deja ete supprime, rafraichir la liste
}
});
});
const handleDelete = async (id: string) => {
clearErrors();
try {
await deleteListing({ id });
} catch (error) {
// L'erreur est geree par onMutationError
}
};
// Donnees partielles meme en cas d'erreur
const listings = computed(() => result.value?.listings ?? []);
</script>
<template>
<div class="safe-listing-fetch">
<!-- Erreur reseau -->
<div v-if="networkError" class="network-error">
<p>{{ networkError }}</p>
<button @click="clearErrors">Fermer</button>
</div>
<!-- Erreurs GraphQL -->
<div v-if="errors.length > 0" class="graphql-errors">
<div v-for="(error, index) in errors" :key="index" class="error-item">
<strong v-if="error.code">{{ error.code }}:</strong>
<p>{{ getReadableError(error.code || 'UNKNOWN') }}</p>
<small v-if="error.path">
Chemin: {{ error.path.join(' > ') }}
</small>
</div>
<button @click="clearErrors">Fermer</button>
</div>
<!-- Chargement -->
<div v-if="loading" class="loading">
Chargement...
</div>
<!-- Liste (peut s'afficher meme avec des erreurs partielles) -->
<div v-if="listings.length > 0" class="listings">
<div v-for="listing in listings" :key="listing.id">
<h3>{{ listing.title }}</h3>
<button @click="handleDelete(listing.id)">
Supprimer
</button>
</div>
</div>
</div>
</template>
Exemple complet : CRUD GraphQL
Voici un exemple complet integrant toutes les fonctionnalites vues precedemment.
// src/composables/useListingsCrud.ts
import { computed, ref } from 'vue';
import { useQuery, useMutation, useSubscription, useApolloClient } from '@vue/apollo-composable';
import {
LISTINGS_QUERY,
LISTING_DETAIL_QUERY,
} from '@/graphql/queries';
import {
CREATE_LISTING_MUTATION,
UPDATE_LISTING_MUTATION,
DELETE_LISTING_MUTATION,
} from '@/graphql/mutations';
import { useErrorHandling } from './useErrorHandling';
import type { Listing, ListingsQueryVariables } from '@/graphql/types';
export function useListingsCrud(initialVariables?: ListingsQueryVariables) {
const { resolveClient } = useApolloClient();
const { handleApolloError, clearErrors, hasErrors, errors } = useErrorHandling();
// State local
const selectedListing = ref<Listing | null>(null);
const isCreating = ref(false);
const isEditing = ref(false);
// ============ READ (Liste) ============
const {
result: listResult,
loading: listLoading,
error: listError,
refetch: refetchList,
fetchMore,
} = useQuery(LISTINGS_QUERY, initialVariables ?? {}, {
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
});
const listings = computed(() => listResult.value?.listings ?? []);
const totalCount = computed(() => listResult.value?.listingsCount ?? 0);
// ============ READ (Detail) ============
const {
result: detailResult,
loading: detailLoading,
refetch: refetchDetail,
} = useQuery(
LISTING_DETAIL_QUERY,
() => ({ id: selectedListing.value?.id }),
{ enabled: () => !!selectedListing.value }
);
const listingDetail = computed(() => detailResult.value?.listing ?? null);
// ============ CREATE ============
const {
mutate: createMutation,
loading: createLoading,
onDone: onCreateDone,
onError: onCreateError,
} = useMutation(CREATE_LISTING_MUTATION, {
update: (cache, { data }) => {
if (!data?.createListing) return;
const existing = cache.readQuery({ query: LISTINGS_QUERY });
if (existing) {
cache.writeQuery({
query: LISTINGS_QUERY,
data: {
...existing,
listings: [data.createListing, ...existing.listings],
listingsCount: existing.listingsCount + 1,
},
});
}
},
});
onCreateDone(() => {
isCreating.value = false;
clearErrors();
});
onCreateError(handleApolloError);
const createListing = async (input: Omit<Listing, 'id' | 'createdAt' | 'updatedAt' | 'owner'>) => {
clearErrors();
return createMutation({ input });
};
// ============ UPDATE ============
const {
mutate: updateMutation,
loading: updateLoading,
onDone: onUpdateDone,
onError: onUpdateError,
} = useMutation(UPDATE_LISTING_MUTATION);
onUpdateDone(() => {
isEditing.value = false;
clearErrors();
});
onUpdateError(handleApolloError);
const updateListing = async (id: string, input: Partial<Listing>) => {
clearErrors();
return updateMutation({ id, input });
};
// ============ DELETE ============
const {
mutate: deleteMutation,
loading: deleteLoading,
onDone: onDeleteDone,
onError: onDeleteError,
} = useMutation(DELETE_LISTING_MUTATION, {
update: (cache, { data }) => {
if (!data?.deleteListing) return;
// Eviction du cache
cache.evict({
id: cache.identify({ __typename: 'Listing', id: data.deleteListing.id }),
});
cache.gc();
},
});
onDeleteDone(() => {
selectedListing.value = null;
});
onDeleteError(handleApolloError);
const deleteListing = async (id: string) => {
clearErrors();
return deleteMutation({ id });
};
// ============ HELPERS ============
const selectListing = (listing: Listing | null) => {
selectedListing.value = listing;
isEditing.value = false;
};
const startCreating = () => {
selectedListing.value = null;
isCreating.value = true;
isEditing.value = false;
};
const startEditing = () => {
isEditing.value = true;
isCreating.value = false;
};
const cancel = () => {
isCreating.value = false;
isEditing.value = false;
clearErrors();
};
const loading = computed(() =>
listLoading.value || createLoading.value || updateLoading.value || deleteLoading.value
);
// ============ PAGINATION ============
const loadMore = async () => {
await fetchMore({
variables: { offset: listings.value.length },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
...fetchMoreResult,
listings: [...prev.listings, ...fetchMoreResult.listings],
};
},
});
};
return {
// Data
listings,
totalCount,
selectedListing,
listingDetail,
// States
loading,
listLoading,
detailLoading,
createLoading,
updateLoading,
deleteLoading,
isCreating,
isEditing,
hasErrors,
errors,
// Actions CRUD
createListing,
updateListing,
deleteListing,
refetchList,
refetchDetail,
loadMore,
// UI Actions
selectListing,
startCreating,
startEditing,
cancel,
clearErrors,
};
}
Bonnes Pratiques
1. Typage strict avec codegen
Utilisez GraphQL Code Generator pour generer automatiquement les types TypeScript :
# codegen.yml
schema: "http://localhost:4000/graphql"
documents: "src/**/*.{ts,vue}"
generates:
src/graphql/generated.ts:
plugins:
- typescript
- typescript-operations
- typed-document-node
2. Fragments pour la reutilisation
// Definir des fragments reutilisables
export const LISTING_FRAGMENT = gql`
fragment ListingFields on Listing {
id
title
description
price
category
images
}
`;
export const LISTINGS_QUERY = gql`
${LISTING_FRAGMENT}
query GetListings {
listings {
...ListingFields
owner {
id
firstName
}
}
}
`;
3. Separation des concerns
- Un composable par domaine metier (
useListings,useUsers,useAuth) - Requetes et mutations dans des fichiers dedies
- Types TypeScript centralises
4. Optimistic UI par defaut
Toujours implementer des mises a jour optimistes pour les actions courantes (like, follow, delete) :
const { mutate } = useMutation(LIKE_MUTATION, {
optimisticResponse: {
toggleLike: {
__typename: 'Listing',
id: listingId,
likesCount: currentLikes + 1,
isLikedByMe: true,
},
},
});
5. Gestion du cache strategique
- Utilisez
cache-and-networkpour les listes frequemment mises a jour - Utilisez
cache-firstpour les donnees stables (profil, parametres) - Evitez
no-cachesauf pour les donnees sensibles
6. Debounce et throttle
Pour les recherches et filtres, toujours utiliser un debounce :
const { result, refetch } = useQuery(SEARCH_QUERY, variables, {
debounce: 300, // Attendre 300ms apres la derniere modification
});
Pieges Courants
1. Oublier de mettre a jour le cache
Probleme: La mutation reussit mais l’UI ne se met pas a jour.
// MAL - Le cache n'est pas mis a jour
const { mutate } = useMutation(DELETE_MUTATION);
await mutate({ id });
// BIEN - Mise a jour du cache
const { mutate } = useMutation(DELETE_MUTATION, {
update: (cache, { data }) => {
cache.evict({ id: cache.identify(data.deleteListing) });
cache.gc();
},
});
2. Variables reactives mal configurees
Probleme: La requete ne se re-execute pas quand les variables changent.
// MAL - Variable non reactive
const { result } = useQuery(QUERY, { id: props.id });
// BIEN - Variable reactive avec fonction ou computed
const { result } = useQuery(QUERY, () => ({ id: props.id }));
// ou
const variables = computed(() => ({ id: props.id }));
const { result } = useQuery(QUERY, variables);
3. Fuites memoire avec les subscriptions
Probleme: Les subscriptions restent actives apres destruction du composant.
// Vue Apollo gere automatiquement le cleanup dans les composants
// Mais attention aux subscriptions manuelles :
import { onUnmounted } from 'vue';
const subscription = apolloClient.subscribe(...).subscribe({
next: (data) => { ... }
});
// Cleanup manuel necessaire
onUnmounted(() => {
subscription.unsubscribe();
});
4. N+1 requetes avec les relations
Probleme: Charger des relations dans une boucle.
// MAL - N+1 requetes
listings.forEach(async (listing) => {
const owner = await fetchOwner(listing.ownerId);
});
// BIEN - Inclure les relations dans la requete initiale
const QUERY = gql`
query GetListings {
listings {
id
title
owner {
id
firstName
}
}
}
`;
5. Ignorer errorPolicy
Probleme: L’application crash sur la premiere erreur GraphQL.
// BIEN - Gerer les erreurs partielles
const { result, error } = useQuery(QUERY, variables, {
errorPolicy: 'all', // Permet de recevoir donnees partielles
});
// Verifier les erreurs dans le resultat
if (error.value) {
// Afficher erreur mais continuer avec donnees disponibles
}
Conclusion
Apollo Client combine avec Vue.js et TypeScript offre une solution complete et robuste pour consommer des APIs GraphQL. Les points cles a retenir :
-
Configuration initiale soignee : Investissez du temps dans la configuration du client, du cache et des liens d’erreur.
-
Typage strict : Utilisez GraphQL Code Generator pour garantir la coherence entre votre schema et votre code TypeScript.
-
Gestion du cache : Maitrisez les differentes strategies de cache et les methodes de manipulation directe.
-
Mises a jour optimistes : Implementez-les systematiquement pour une UX fluide.
-
Temps reel : Les subscriptions apportent une dimension temps reel a vos applications.
-
Gestion d’erreurs robuste : Prevoyez tous les scenarios d’erreur avec des messages conviviaux.
En appliquant ces principes et bonnes pratiques, vous construirez des applications Vue.js performantes, maintenables et agreables a utiliser. Apollo Client continuera d’evoluer, et Vue Apollo restera un excellent choix pour integrer GraphQL dans l’ecosysteme Vue.
Ressources supplementaires
In-Article Ad
Dev Mode
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Integrer GraphQL et Vue Apollo : Requetes, mutations et gestion du cache
Configurez Vue Apollo avec GraphQL pour vos applications Vue.js. Executez des requetes et mutations avec une gestion optimale du cache.
Vue Apollo Composable : useQuery et useMutation pour vos operations GraphQL
Exploitez useQuery et useMutation de Vue Apollo Composable pour gerer vos requetes GraphQL. Guide pratique avec gestion du loading, erreurs, cache et optimistic updates.
Securiser votre application Vue.js avec Vue Router et les jetons JWT
Implementez une authentification securisee avec Vue Router et Vuex. Gerez les jetons JWT et protegez vos routes sensibles efficacement.