Apollo Client ile React Uygulamasına GraphQL Entegrasyonu

GraphQL’i ilk öğrendiğimde, REST API’lere göre ne kadar büyük bir zihniyet değişikliği gerektirdiğini fark etmem biraz zaman aldı. Ama Apollo Client ile React’i bir araya getirdiğimde, “neden bu kadar geç kaldım?” dedim açıkçası. Eğer siz de hâlâ her component için ayrı ayrı useEffect + fetch yazıyorsanız ve loading state’lerini elle yönetmekten bıktıysanız, bu yazı tam size göre.

Apollo Client Nedir ve Neden Kullanmalıyız?

Apollo Client, sadece bir HTTP istemcisi değil. Cache yönetimi, reactive variables, optimistic updates, error handling… Bunların hepsini tek bir paket altında sunuyor. Düşünün ki her GraphQL sorgusundan dönen veriyi otomatik olarak normalize edip cache’e yazıyor, aynı veriyi bir daha sunucudan çekmeden direkt cache’den sunuyor.

REST dünyasında bunu kendiniz yazmak için ya Redux + custom middleware kuruyordunuz ya da React Query gibi bir kütüphane ekliyordunuz. Apollo, GraphQL ekosistemi için bunu neredeyse kutudan çıkar çıkmaz sağlıyor.

Büyük bir e-ticaret projesinde çalışırken, ürün detay sayfasına her gidip döndüğümüzde API’ye tekrar istek atıldığını fark ettik. Apollo’nun cache mekanizmasını doğru configure ettiğimizde, aynı ürün için saniyeler içinde ikinci bir network isteği gitmiyordu. Kullanıcı deneyimi ciddi ölçüde iyileşti.

Kurulum ve Temel Yapılandırma

Önce gerekli paketleri kuralım:

npm install @apollo/client graphql
# ya da yarn kullanıyorsanız
yarn add @apollo/client graphql

Burada graphql paketi Apollo’nun peer dependency’si, atlamayın. Şimdi Apollo Client’ı oluşturup uygulamamıza bağlayalım:

# src/apollo/client.ts dosyasını oluşturalım
// src/apollo/client.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({
  uri: process.env.REACT_APP_GRAPHQL_URL || 'http://localhost:4000/graphql',
});

// Auth token'ı her isteğe ekleyen middleware
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('authToken');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

export const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache({
    typePolicies: {
      Product: {
        keyFields: ['id', 'sku'], // Cache key olarak id ve sku kullan
      },
    },
  }),
  defaultOptions: {
    watchQuery: {
      errorPolicy: 'all',
    },
  },
});

typePolicies kısmı çoğu kişinin atlayıp sonradan pişman olduğu bir detay. Eğer backend’iniz cache’i doğru normalize edemiyorsa, aynı ürünü farklı query’lerden getirdiğinizde cache’de iki ayrı kayıt oluşabilir. Bu da stale data sorunlarına yol açar.

Şimdi bu client’ı React uygulamamıza bağlayalım:

// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ApolloProvider } from '@apollo/client';
import { client } from './apollo/client';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
);

ApolloProvider Context API üzerinden çalıştığı için, bu wrapper’ın altındaki her component useQuery, useMutation gibi hook’lara erişebilir.

useQuery Hook ile Veri Çekme

En sık kullanacağınız hook bu olacak. Basit bir ürün listesi örneğiyle başlayalım:

// src/components/ProductList.tsx
import React from 'react';
import { useQuery, gql } from '@apollo/client';

const GET_PRODUCTS = gql`
  query GetProducts($category: String, $limit: Int) {
    products(category: $category, limit: $limit) {
      id
      sku
      name
      price
      stock
      category {
        id
        name
      }
      images {
        url
        alt
      }
    }
  }
`;

interface Product {
  id: string;
  sku: string;
  name: string;
  price: number;
  stock: number;
  category: { id: string; name: string };
  images: { url: string; alt: string }[];
}

const ProductList: React.FC<{ category?: string }> = ({ category }) => {
  const { loading, error, data, refetch } = useQuery(GET_PRODUCTS, {
    variables: {
      category,
      limit: 20,
    },
    fetchPolicy: 'cache-and-network', // Cache'i göster, arka planda güncelle
    notifyOnNetworkStatusChange: true,
  });

  if (loading && !data) return <div>Yükleniyor...</div>;
  
  if (error) {
    console.error('GraphQL Error:', error);
    return (
      <div>
        <p>Ürünler yüklenemedi: {error.message}</p>
        <button onClick={() => refetch()}>Tekrar Dene</button>
      </div>
    );
  }

  const products: Product[] = data?.products || [];

  return (
    <div className="product-grid">
      {products.map((product) => (
        <div key={product.id} className="product-card">
          <img src={product.images[0]?.url} alt={product.images[0]?.alt} />
          <h3>{product.name}</h3>
          <p>{product.price} TL</p>
          <span className={product.stock > 0 ? 'in-stock' : 'out-of-stock'}>
            {product.stock > 0 ? 'Stokta var' : 'Tükendi'}
          </span>
        </div>
      ))}
    </div>
  );
};

export default ProductList;

fetchPolicy seçenekleri konusunda biraz duralım çünkü bu kararı yanlış verirseniz ya çok fazla network isteği atarsınız ya da kullanıcıya bayat veri gösterirsiniz:

  • cache-first: Önce cache’e bak, varsa network’e gitme. Default değer bu.
  • cache-and-network: Cache’deki veriyi hemen göster, arka planda sunucudan güncelle.
  • network-only: Her seferinde sunucudan çek, cache’i güncelle ama okuma.
  • no-cache: Sunucudan çek, cache’e bile yazma.
  • cache-only: Sadece cache’den oku, hata olsa bile network’e gitme.

Gerçek dünya senaryosunda genellikle cache-and-network kullanmak en iyi kullanıcı deneyimini sağlıyor. Sayfa hızlı yükleniyor, arka planda da taze veri geliyor.

useMutation ile Veri Yazma

Query’leri hallettik, şimdi mutation’lara bakalım. Sepete ürün ekleme örneği üzerinden gidelim:

// src/components/AddToCartButton.tsx
import React, { useState } from 'react';
import { useMutation, gql } from '@apollo/client';

const ADD_TO_CART = gql`
  mutation AddToCart($productId: ID!, $quantity: Int!) {
    addToCart(productId: $productId, quantity: $quantity) {
      success
      message
      cart {
        id
        totalItems
        totalPrice
        items {
          id
          product {
            id
            name
            price
          }
          quantity
        }
      }
    }
  }
`;

const GET_CART = gql`
  query GetCart {
    cart {
      id
      totalItems
      totalPrice
    }
  }
`;

interface AddToCartButtonProps {
  productId: string;
  disabled?: boolean;
}

const AddToCartButton: React.FC<AddToCartButtonProps> = ({ 
  productId, 
  disabled 
}) => {
  const [quantity, setQuantity] = useState(1);

  const [addToCart, { loading, error }] = useMutation(ADD_TO_CART, {
    variables: { productId, quantity },
    // Mutation sonrası cache'i güncelle
    update(cache, { data: { addToCart } }) {
      if (addToCart.success) {
        // Mevcut cart query'sini cache'de güncelle
        cache.writeQuery({
          query: GET_CART,
          data: { cart: addToCart.cart },
        });
      }
    },
    onCompleted(data) {
      if (data.addToCart.success) {
        alert('Ürün sepete eklendi!');
      } else {
        alert(data.addToCart.message);
      }
    },
    onError(err) {
      console.error('Sepete ekleme hatası:', err);
      // Sentry veya benzeri bir servise gönderebilirsiniz
    },
  });

  return (
    <div className="add-to-cart">
      <input
        type="number"
        min="1"
        max="99"
        value={quantity}
        onChange={(e) => setQuantity(parseInt(e.target.value))}
      />
      <button
        onClick={() => addToCart()}
        disabled={loading || disabled}
      >
        {loading ? 'Ekleniyor...' : 'Sepete Ekle'}
      </button>
      {error && (
        <p className="error">Bir hata oluştu, lütfen tekrar deneyin.</p>
      )}
    </div>
  );
};

export default AddToCartButton;

update fonksiyonu burada kritik. Mutation tamamlandığında Apollo’ya “bu cache entry’leri de güncelle” diyoruz. Bunu yapmazsanız, kullanıcı sepet sayfasına gittiğinde eski veriyi görür.

Optimistic Updates ile Anlık Geri Bildirim

Kullanıcı bir butona bastığında sonucu beklemeden arayüzü güncellemek istiyorsanız optimistic updates kullanın. Özellikle like/beğeni sistemlerinde veya stok sayaçlarında çok işe yarıyor:

// src/components/WishlistButton.tsx
import React from 'react';
import { useMutation, gql, useApolloClient } from '@apollo/client';

const TOGGLE_WISHLIST = gql`
  mutation ToggleWishlist($productId: ID!) {
    toggleWishlist(productId: $productId) {
      id
      isInWishlist
      wishlistCount
    }
  }
`;

const WishlistButton: React.FC<{ 
  productId: string;
  isInWishlist: boolean;
  wishlistCount: number;
}> = ({ productId, isInWishlist, wishlistCount }) => {
  
  const [toggleWishlist] = useMutation(TOGGLE_WISHLIST, {
    variables: { productId },
    optimisticResponse: {
      toggleWishlist: {
        __typename: 'Product',
        id: productId,
        isInWishlist: !isInWishlist, // Anlık toggle
        wishlistCount: isInWishlist 
          ? wishlistCount - 1 
          : wishlistCount + 1,
      },
    },
  });

  return (
    <button
      onClick={() => toggleWishlist()}
      className={isInWishlist ? 'wishlisted' : 'not-wishlisted'}
    >
      {isInWishlist ? '❤️' : '🤍'} {wishlistCount}
    </button>
  );
};

export default WishlistButton;

optimisticResponse ile UI anında güncelleniyor. Eğer sunucu isteği başarısız olursa Apollo otomatik olarak cache’i eski haline döndürüyor. Bu rollback mekanizması kutudan çıkar çıkmaz geliyor, kendiniz yazmak zorunda değilsiniz.

Subscription ile Gerçek Zamanlı Veri

WebSocket üzerinden gerçek zamanlı veri akışı için subscription’ları kullanıyoruz. Önce WebSocket link’i ekleyelim:

// src/apollo/client.ts - WebSocket destekli versiyon
import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  split,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({
  uri: process.env.REACT_APP_GRAPHQL_URL || 'http://localhost:4000/graphql',
});

const wsLink = new GraphQLWsLink(
  createClient({
    url: process.env.REACT_APP_GRAPHQL_WS_URL || 'ws://localhost:4000/graphql',
    connectionParams: () => {
      const token = localStorage.getItem('authToken');
      return { authorization: token ? `Bearer ${token}` : '' };
    },
    retryAttempts: 5, // Bağlantı koptuğunda 5 kez tekrar dene
  })
);

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('authToken');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

// Subscription ise WebSocket, değilse HTTP kullan
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  authLink.concat(httpLink)
);

export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

Şimdi gerçek zamanlı stok takibi için bir subscription kullanalım:

// src/components/StockTracker.tsx
import React from 'react';
import { useSubscription, gql } from '@apollo/client';

const STOCK_UPDATED = gql`
  subscription OnStockUpdated($productId: ID!) {
    stockUpdated(productId: $productId) {
      productId
      stock
      updatedAt
    }
  }
`;

const StockTracker: React.FC<{ productId: string; initialStock: number }> = ({
  productId,
  initialStock,
}) => {
  const { data, loading } = useSubscription(STOCK_UPDATED, {
    variables: { productId },
    onData({ data: { data } }) {
      if (data?.stockUpdated.stock === 0) {
        // Stok tükendi bildirimi
        console.warn(`Ürün ${productId} stoku tükendi!`);
      }
    },
  });

  const currentStock = data?.stockUpdated.stock ?? initialStock;

  return (
    <div className="stock-tracker">
      <span className={currentStock > 0 ? 'available' : 'unavailable'}>
        {loading ? 'Bağlanıyor...' : `Stok: ${currentStock}`}
      </span>
      {currentStock > 0 && currentStock <= 5 && (
        <span className="warning">Son {currentStock} ürün!</span>
      )}
    </div>
  );
};

export default StockTracker;

WebSocket bağlantısında dikkat etmeniz gereken bir nokta: production’da load balancer kullanıyorsanız sticky session’ları etkinleştirmeniz gerekiyor. Aksi hâlde WebSocket bağlantısı sürekli kopup yeniden bağlanmaya çalışır.

Error Handling ve Apollo Error Policies

Üretim ortamında error handling’i ciddiye almak gerekiyor. Apollo’nun error objesi aslında iki farklı hata tipini barındırıyor: network hataları ve GraphQL hataları. Bunları birbirine karıştırmak sık yapılan bir hata.

// src/hooks/useProductDetail.ts
import { useQuery, gql } from '@apollo/client';

const GET_PRODUCT_DETAIL = gql`
  query GetProductDetail($id: ID!) {
    product(id: $id) {
      id
      name
      description
      price
      stock
      reviews {
        id
        rating
        comment
        user {
          id
          name
        }
      }
    }
  }
`;

export const useProductDetail = (productId: string) => {
  const { loading, error, data, networkStatus } = useQuery(GET_PRODUCT_DETAIL, {
    variables: { id: productId },
    errorPolicy: 'partial', // Kısmi veri gelirse de göster
    notifyOnNetworkStatusChange: true,
    skip: !productId, // productId yoksa query'yi çalıştırma
  });

  // Network hatası (sunucu erişilemiyor)
  const isNetworkError = error?.networkError !== null;
  
  // GraphQL hatası (validation, authorization vb.)
  const graphqlErrors = error?.graphQLErrors || [];
  
  const isAuthError = graphqlErrors.some(
    (err) => err.extensions?.code === 'UNAUTHENTICATED'
  );

  return {
    product: data?.product,
    loading,
    networkStatus,
    isNetworkError,
    isAuthError,
    graphqlErrors,
  };
};

errorPolicy: 'partial' kullandığınızda, örneğin reviews bölümünde bir hata olsa bile product’ın diğer alanları geliyorsa Apollo bunları size veriyor. Çoğu durumda kullanıcıya kısmi veri göstermek hiç veri göstermemekten daha iyi.

Fragment Kullanımı ve Kod Organizasyonu

Büyük projelerde aynı alanları birden fazla query’de tekrar yazmak kaçınılmaz hâle geliyor. Fragment’lar bu sorunu çözüyor:

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

export const PRODUCT_BASE_FIELDS = gql`
  fragment ProductBaseFields on Product {
    id
    sku
    name
    price
    stock
    images {
      url
      alt
    }
  }
`;

export const PRODUCT_DETAIL_FIELDS = gql`
  fragment ProductDetailFields on Product {
    ...ProductBaseFields
    description
    specifications {
      key
      value
    }
    category {
      id
      name
      slug
    }
  }
  ${PRODUCT_BASE_FIELDS}
`;
// src/components/FeaturedProducts.tsx
import { useQuery, gql } from '@apollo/client';
import { PRODUCT_BASE_FIELDS } from '../graphql/fragments';

const GET_FEATURED = gql`
  query GetFeatured {
    featuredProducts {
      ...ProductBaseFields
    }
  }
  ${PRODUCT_BASE_FIELDS}
`;

Fragment’ları ayrı bir dosyada tutmak ve import ederek kullanmak, query’lerdeki tekrarı ciddi ölçüde azaltıyor. Özellikle 20-30 farklı query olan projelerde bu organizasyonun değeri ortaya çıkıyor.

Apollo DevTools ile Debug

Geliştirme sürecinde Chrome için Apollo Client DevTools eklentisini mutlaka kurun:

# .env.development dosyanıza ekleyin
REACT_APP_GRAPHQL_URL=http://localhost:4000/graphql
REACT_APP_GRAPHQL_WS_URL=ws://localhost:4000/graphql

Client’ınızı development için ayrı yapılandırabilirsiniz:

// src/apollo/client.ts içinde
export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
  connectToDevTools: process.env.NODE_ENV === 'development',
  // Development'ta cache inspection için
  ...(process.env.NODE_ENV === 'development' && {
    defaultOptions: {
      watchQuery: {
        fetchPolicy: 'network-only', // Cache sorunlarını maskeleme
      },
    },
  }),
});

DevTools ile hangi query’lerin çalıştığını, cache’de ne olduğunu ve mutation geçmişini görebiliyorsunuz. Özellikle “neden bu component re-render olmadı?” ya da “cache neden güncellenmiyor?” sorularının cevabını bulmak çok kolaylaşıyor.

Performans Optimizasyonu: useLazyQuery ve Pagination

Her zaman component mount olduğunda query çalıştırmak istemeyebilirsiniz. Kullanıcı bir butona bastığında ya da belirli bir event gerçekleştiğinde çalıştırmak için useLazyQuery kullanın. Bunun yanında büyük veri setlerinde pagination kaçınılmaz:

// src/components/ProductSearch.tsx
import React, { useState } from 'react';
import { useLazyQuery, gql } from '@apollo/client';

const SEARCH_PRODUCTS = gql`
  query SearchProducts($query: String!, $page: Int!, $pageSize: Int!) {
    searchProducts(query: $query, page: $page, pageSize: $pageSize) {
      items {
        id
        name
        price
        images { url alt }
      }
      pagination {
        currentPage
        totalPages
        totalItems
        hasNextPage
      }
    }
  }
`;

const ProductSearch: React.FC = () => {
  const [searchQuery, setSearchQuery] = useState('');
  const [currentPage, setCurrentPage] = useState(1);

  const [searchProducts, { loading, data, fetchMore }] = useLazyQuery(
    SEARCH_PRODUCTS,
    {
      fetchPolicy: 'cache-and-network',
    }
  );

  const handleSearch = () => {
    if (searchQuery.trim()) {
      setCurrentPage(1);
      searchProducts({
        variables: { query: searchQuery, page: 1, pageSize: 12 },
      });
    }
  };

  const handleLoadMore = () => {
    const nextPage = currentPage + 1;
    fetchMore({
      variables: {
        query: searchQuery,
        page: nextPage,
        pageSize: 12,
      },
      updateQuery(previousResult, { fetchMoreResult }) {
        if (!fetchMoreResult) return previousResult;
        return {
          searchProducts: {
            ...fetchMoreResult.searchProducts,
            items: [
              ...previousResult.searchProducts.items,
              ...fetchMoreResult.searchProducts.items,
            ],
          },
        };
      },
    });
    setCurrentPage(nextPage);
  };

  const results = data?.searchProducts;

  return (
    <div>
      <div className="search-bar">
        <input
          type="text"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
          placeholder="Ürün ara..."
        />
        <button onClick={handleSearch} disabled={loading}>
          {loading ? 'Aranıyor...' : 'Ara'}
        </button>
      </div>

      {results && (
        <>
          <p>{results.pagination.totalItems} ürün bulundu</p>
          <div className="results-grid">
            {results.items.map((item: any) => (
              <div key={item.id}>{item.name} - {item.price} TL</div>
            ))}
          </div>
          {results.pagination.hasNextPage && (
            <button onClick={handleLoadMore} disabled={loading}>
              Daha Fazla Göster
            </button>
          )}
        </>
      )}
    </div>
  );
};

export default ProductSearch;

fetchMore ile infinite scroll ya da “daha fazla göster” yapısı kurarken updateQuery fonksiyonunda önceki sonuçlarla yeni sonuçları birleştirmeyi unutmayın. Bunu yapmazsanız her sayfada önceki sonuçlar kayboluyor.

Sonuç

Apollo Client ile React’i entegre etmek başta biraz öğrenme eğrisi gerektiriyor, özellikle cache mekanizmasını kavramak zaman alıyor. Ama bir kez oturduktan sonra, her şeyi manuel yazmaktan ne kadar zaman kazandırdığını açıkça görüyorsunuz.

Dikkat etmeniz gereken birkaç kritik nokta var:

  • Cache’i doğru yapılandırın: typePolicies ve keyFields ayarlarını ihmal etmeyin. Yoksa normalize edilmemiş cache size production’da baş ağrıtır.
  • fetchPolicy’i duruma göre seçin: Her query için aynı politika uygulamayın. Sık değişen veriler için cache-and-network, nadiren değişenler için cache-first tercih edin.
  • Mutation sonrası cache güncellemesini unutmayın: update callback’i ya da refetchQueries kullanarak ilgili query’leri güncelleyin.
  • Error handling’i katmanlı düşünün: Network hatalarını ve GraphQL hatalarını farklı şekillerde ele alın.
  • Fragment’ları erken benimseyin: Proje büyüdükçe fragment olmadan query’leri yönetmek zorlaşıyor.

WebSocket subscription’larını production’a taşırken load balancer sticky session ayarlarına dikkat edin. Bunu atlayan birkaç ekip gördüm, saatlerce “bağlantı neden kesiliyor?” diye uğraştılar.

Apollo ekosistemi aktif olarak gelişiyor, Apollo Client 4 sürümü de geliyor. Mevcut API’lerin büyük kısmı korunuyor ama bazı breaking change’ler olacak. Release note’ları takip etmenizi öneririm.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir