Vue.js et Apollo Client : Résoudre les problèmes de cache GraphQL

Apprenez à mettre à jour le cache Apollo Client après une mutation GraphQL dans Vue.js. Guide complet avec useQuery, useMutation et refetch.

Mahmoud DEVO
Mahmoud DEVO
December 28, 2025 11 min read
Vue.js et Apollo Client : Résoudre les problèmes de cache GraphQL

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 :

AspectRESTGraphQL
Recuperation des donneesEndpoints multiples, over-fetching frequentUne seule requete, donnees exactes
TypageDocumentation externe (OpenAPI)Schema auto-documente et type
VersioningURLs versionnees (/v1/, /v2/)Evolution du schema sans breaking changes
Performance reseauN+1 requetes possiblesUne requete = une reponse
Experience developpeurVariable selon l’APIOutils 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)

PolicyDescriptionCas d’utilisation
cache-firstCache d’abord, reseau si absentDonnees stables (profil utilisateur)
cache-and-networkCache immediat + mise a jour reseauListes frequemment mises a jour
network-onlyToujours depuis le reseauDonnees critiques (paiements)
cache-onlyUniquement depuis le cacheDonnees pre-chargees
no-cachePas de cache, pas de stockageDonnees sensibles
standbyNe pas executer automatiquementRequetes 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-network pour les listes frequemment mises a jour
  • Utilisez cache-first pour les donnees stables (profil, parametres)
  • Evitez no-cache sauf 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 :

  1. Configuration initiale soignee : Investissez du temps dans la configuration du client, du cache et des liens d’erreur.

  2. Typage strict : Utilisez GraphQL Code Generator pour garantir la coherence entre votre schema et votre code TypeScript.

  3. Gestion du cache : Maitrisez les differentes strategies de cache et les methodes de manipulation directe.

  4. Mises a jour optimistes : Implementez-les systematiquement pour une UX fluide.

  5. Temps reel : Les subscriptions apportent une dimension temps reel a vos applications.

  6. 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

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

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