Apollo Client ile Veri Önbellekleme ve Optimistic UI

Bir e-ticaret projesinde çalışırken şunu fark ettim: kullanıcı sepete ürün eklediğinde sunucudan yanıt beklemek zorunda değil aslında. Sepet sayacı anında güncellenebilir, ürün listesi hemen değişebilir. Eğer sunucu işlemi başarısız olursa geri alırsınız. İşte bu düşünce beni Apollo Client’ın önbellekleme mimarisi ve Optimistic UI özelliklerine derinlemesine bakmaya itti.

Apollo Client, React ekosisteminde GraphQL ile çalışırken sadece veri çekme katmanı değil, aynı zamanda sofistike bir durum yönetim sistemi olarak davranıyor. Ama bu güç beraberinde anlaşılması gereken birkaç kritik kavramı getiriyor: InMemoryCache nasıl çalışır, normalize edilmiş önbellek ne anlama gelir, ve Optimistic UI’ı doğru implement etmek için ne bilmem gerekir?

InMemoryCache’in Gerçek Yüzü

Apollo Client’ın kalbi InMemoryCache‘dir. Çoğu developer bunu “bir çeşit önbellek” olarak tanımlayıp geçiyor, ama altında oldukça zekice bir normalize etme mekanizması var.

Bir User objesi döndüren sorgularınız olduğunda Apollo, bu objeyi bir key-value store’da saklar. Varsayılan olarak her obje için benzersiz bir tanımlayıcı oluşturur. Bu tanımlayıcı genellikle __typename:id formatındadır. Yani User:42 gibi.

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

const client = new ApolloClient({
  uri: 'https://api.sirketim.com/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      User: {
        keyFields: ['id'],
      },
      Product: {
        keyFields: ['sku'], // id yerine sku kullanıyoruz
      },
      // id'si olmayan tipler için
      CartItem: {
        keyFields: ['productId', 'variantId'],
      },
    },
  }),
});

Buradaki typePolicies kısmı kritik. Eğer API’nızdan dönen objelerin id alanı yoksa ya da farklı bir benzersiz tanımlayıcı kullanıyorsa, Apollo’ya bunu söylemeniz gerekiyor. Aksi halde önbellek normalize edilemez ve her sorgu için ayrı entry oluşturulur, bu da bellek israfına ve tutarsızlığa yol açar.

Gerçek dünya senaryosunda karşılaştığım bir sorun: bir fintech projesinde hesap objeleri accountNumber ve branchCode kombinasyonuyla benzersiz tanımlanıyordu. Bunu Apollo’ya şöyle anlattık:

Account: {
  keyFields: ['accountNumber', 'branchCode'],
},

Bu olmadan iki farklı sorguda aynı hesap geldiğinde Apollo bunları birbirine bağlayamıyordu ve UI tutarsız davranıyordu.

Field Policy ve Read/Write Fonksiyonları

InMemoryCache’in gerçek gücü field policies‘de gizli. Her alanın önbellekten nasıl okunacağını ve yazılacağını tanımlayabilirsiniz.

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        // Fiyatı her zaman KDV dahil göster
        price: {
          read(originalPrice) {
            return originalPrice * 1.18;
          },
        },
        // İndirim yüzdesini hesapla
        discountPercentage: {
          read(_, { readField }) {
            const originalPrice = readField('originalPrice');
            const currentPrice = readField('price');
            if (!originalPrice || originalPrice === currentPrice) return 0;
            return Math.round(((originalPrice - currentPrice) / originalPrice) * 100);
          },
        },
      },
    },
  },
});

Bu yaklaşımın güzelliği şu: sunucudan discountPercentage alanı gelmiyor, ama siz bunu önbellek katmanında hesaplayıp sanki sunucudan geliyormuş gibi kullanabiliyorsunuz. Component’larınız bu detayı bilmek zorunda değil.

Sayfalandırma ve Önbellek Birleştirme

Pagination Apollo’nun en çok başağrıtıcı konularından biri. Cursor-based pagination kullandığınızda “daha fazla yükle” butonuna basıldığında yeni gelen verinin önbelleği nasıl güncelleyeceğini Apollo’ya söylemeniz gerekiyor.

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        products: {
          // Cursor tabanlı pagination için keyArgs
          keyArgs: ['category', 'sortBy', 'filters'],
          merge(existing = { edges: [], pageInfo: {} }, incoming) {
            return {
              ...incoming,
              edges: [
                ...(existing.edges || []),
                ...incoming.edges,
              ],
            };
          },
        },
      },
    },
  },
});

keyArgs burada kritik. Apollo’ya hangi argümanlar önbellek key’ini belirliyor, hangisi belirlemiyorsa söylüyorsunuz. after ve first cursor argümanlarını keyArgs‘a eklemezseniz, aynı kategori altındaki tüm sayfalar birleşiyor. Eklerseniz her sayfa ayrı önbellek entry’si oluyor ve birleştirme (merge) çalışmıyor.

Bir blog platformu projesinde şöyle bir yapı kurmuştuk:

Query: {
  fields: {
    posts: {
      keyArgs: ['authorId', 'status', 'tags'],
      merge(existing, incoming, { args }) {
        const merged = existing ? [...existing] : [];
        
        if (args) {
          const { offset = 0 } = args;
          for (let i = 0; i < incoming.length; ++i) {
            merged[offset + i] = incoming[i];
          }
        } else {
          merged.push.apply(merged, incoming);
        }
        
        return merged;
      },
      
      read(existing, { args }) {
        if (args) {
          const { offset = 0, limit = existing?.length } = args;
          return existing && existing.slice(offset, offset + limit);
        }
        return existing;
      },
    },
  },
},

Bu offset-based pagination için çalışan bir merge stratejisi. Hem read hem merge tanımladığınızda Apollo, önbelleği hem yazarken hem okurken bu fonksiyonları kullanıyor.

Optimistic UI: Kullanıcıyı Beklettirme

Şimdi asıl konuya gelelim. Optimistic UI, kullanıcının bir aksiyon aldığında (beğenmek, sepete eklemek, yorumlamak) sunucudan yanıt beklenmeden UI’ın anında güncellenmesi anlamına geliyor.

Apollo bunu optimisticResponse seçeneğiyle destekliyor. Bir mutation’ı ateşlediğinizde önce optimistik yanıtı önbelleğe yazıyor, gerçek yanıt gelince değiştiriyor, hata olursa geri alıyor.

const [addToCart] = useMutation(ADD_TO_CART_MUTATION, {
  optimisticResponse: {
    addToCart: {
      __typename: 'CartItem',
      id: 'temp-' + Date.now(), // Geçici ID
      productId: product.id,
      quantity: 1,
      product: {
        __typename: 'Product',
        id: product.id,
        name: product.name,
        price: product.price,
        imageUrl: product.imageUrl,
      },
    },
  },
  update(cache, { data: { addToCart } }) {
    cache.modify({
      fields: {
        cartItems(existingItems = []) {
          const newItemRef = cache.writeFragment({
            data: addToCart,
            fragment: gql`
              fragment NewCartItem on CartItem {
                id
                productId
                quantity
                product {
                  id
                  name
                  price
                  imageUrl
                }
              }
            `,
          });
          return [...existingItems, newItemRef];
        },
      },
    });
  },
});

Buradaki update fonksiyonu hem optimistik yanıtla hem de gerçek yanıtla çağrılıyor. Apollo, optimistik sonuçla update’i çalıştırıyor, önbelleği güncelliyor. Gerçek yanıt geldiğinde tekrar update’i çalıştırıyor ve geçici ID yerine gerçek ID geliyor.

cache.modify ile Hassas Önbellek Güncellemeleri

refetchQueries kullanmak kolay ama pahalı. Bir mutation sonrası tüm ilgili sorguyu yeniden çekmek yerine, önbelleği cerrahi olarak güncellemek çok daha performanslı.

const [likePost] = useMutation(LIKE_POST_MUTATION, {
  optimisticResponse: ({ postId }) => ({
    likePost: {
      __typename: 'Post',
      id: postId,
      likeCount: currentPost.likeCount + 1,
      isLikedByMe: true,
    },
  }),
  update(cache, { data: { likePost } }) {
    cache.modify({
      id: cache.identify({ __typename: 'Post', id: likePost.id }),
      fields: {
        likeCount(prev) {
          return likePost.likeCount;
        },
        isLikedByMe() {
          return true;
        },
      },
    });
  },
});

cache.identify() metodu, bir objenin önbellekteki key’ini döndürüyor. Bu sayede hangi objeyi güncellemek istediğinizi tam olarak belirtebiliyorsunuz.

Gerçek bir sosyal platform projesinde şöyle bir senaryo vardı: kullanıcı bir içeriği kaydettiğinde hem Post objesindeki savedCount ve isSavedByMe güncellenmeli, hem de ayrı bir SavedPosts listesi güncellenmeliydi. Tek bir mutation sonrası iki farklı önbellek lokasyonunu güncellemek:

update(cache, { data: { savePost } }) {
  // Post objesini güncelle
  cache.modify({
    id: cache.identify({ __typename: 'Post', id: savePost.id }),
    fields: {
      savedCount: (prev) => prev + 1,
      isSavedByMe: () => true,
    },
  });

  // Kayıtlı postlar listesine ekle
  const savedPostRef = cache.writeFragment({
    data: savePost,
    fragment: gql`
      fragment SavedPost on Post {
        id
        title
        thumbnailUrl
        savedAt
      }
    `,
  });

  cache.modify({
    fields: {
      savedPosts(existing = []) {
        return [savedPostRef, ...existing];
      },
    },
  });
},

Hata Durumunda Geri Alma Stratejileri

Optimistic UI’ın en kritik kısmı hata yönetimi. Kullanıcı bir aksiyon aldı, UI güncellendi, ama sunucu hata döndürdü. Ne yapacaksınız?

Apollo otomatik olarak optimistik değişiklikleri geri alıyor eğer mutation hata döndürürse. Ama kullanıcıya bunu bildirmek ve iyi bir UX sunmak sizin sorumluluğunuz.

const [deleteComment] = useMutation(DELETE_COMMENT_MUTATION, {
  optimisticResponse: {
    deleteComment: {
      __typename: 'Comment',
      id: commentId,
      deleted: true,
    },
  },
  update(cache, { data: { deleteComment } }) {
    cache.modify({
      id: cache.identify({ __typename: 'Post', id: postId }),
      fields: {
        comments(existingComments, { readField }) {
          return existingComments.filter(
            (commentRef) => readField('id', commentRef) !== deleteComment.id
          );
        },
        commentCount(prev) {
          return prev - 1;
        },
      },
    });
  },
  onError(error) {
    // Apollo otomatik geri aldı, kullanıcıya bildir
    showNotification({
      type: 'error',
      message: 'Yorum silinemedi. Lütfen tekrar deneyin.',
      description: error.message,
    });
    
    // Gerekirse analytics'e gönder
    trackError('DELETE_COMMENT_FAILED', { commentId, error: error.message });
  },
  onCompleted() {
    showNotification({
      type: 'success',
      message: 'Yorum başarıyla silindi.',
    });
  },
});

onError callback’ine geldiğinizde Apollo zaten optimistik değişiklikleri geri almış oluyor. Sizin yapmanız gereken kullanıcıya durumu anlatmak.

Önbellek Kalıcılığı ve Hydration

Tarayıcıyı yenilediğinizde Apollo önbelleği sıfırlanıyor. Eğer önbelleği kalıcı hale getirmek istiyorsanız apollo3-cache-persist kütüphanesini kullanabilirsiniz.

import { InMemoryCache } from '@apollo/client';
import { persistCache, LocalStorageWrapper } from 'apollo3-cache-persist';

const cache = new InMemoryCache();

async function initializeApollo() {
  await persistCache({
    cache,
    storage: new LocalStorageWrapper(window.localStorage),
    maxSize: 1048576, // 1MB limit
    debug: process.env.NODE_ENV === 'development',
  });

  const client = new ApolloClient({
    uri: process.env.REACT_APP_GRAPHQL_URL,
    cache,
  });

  return client;
}

Ama dikkatli olun: localStorage’daki eski veri ile yeni verinin çakışması sorun yaratabilir. Production’da bunu yönetmek için versiyon mekanizması eklemek iyi bir pratik:

const CACHE_VERSION = '2.1.0';
const storedVersion = localStorage.getItem('cache-version');

if (storedVersion !== CACHE_VERSION) {
  await cache.reset();
  localStorage.setItem('cache-version', CACHE_VERSION);
}

await persistCache({
  cache,
  storage: new LocalStorageWrapper(window.localStorage),
});

Reactive Variables ile Local State

Apollo Client’ın bir diğer güçlü özelliği makeVar ile oluşturulan reactive variables. Bunlar önbellek dışında tutulan ama bileşenleri re-render ettirebilen değerler.

import { makeVar, useReactiveVar } from '@apollo/client';

// Global state olarak kullanılabilir
export const isCartOpenVar = makeVar(false);
export const selectedFiltersVar = makeVar({
  category: null,
  priceRange: [0, 10000],
  brands: [],
  inStock: false,
});

// Bir bileşende
function FilterPanel() {
  const filters = useReactiveVar(selectedFiltersVar);
  
  const updateFilter = (key, value) => {
    selectedFiltersVar({
      ...selectedFiltersVar(),
      [key]: value,
    });
  };
  
  return (
    <div>
      <PriceRangeSlider
        value={filters.priceRange}
        onChange={(range) => updateFilter('priceRange', range)}
      />
      {/* diğer filtreler */}
    </div>
  );
}

Bu reactive variable’ları GraphQL sorgularınızla da birleştirebilirsiniz:

const GET_PRODUCTS = gql`
  query GetProducts($filters: ProductFilters!) {
    products(filters: $filters) {
      id
      name
      price
    }
  }
`;

function ProductList() {
  const filters = useReactiveVar(selectedFiltersVar);
  
  const { data, loading } = useQuery(GET_PRODUCTS, {
    variables: { filters },
  });
  
  // filters değiştiğinde sorgu otomatik yeniden çalışır
}

fetchPolicy Stratejileri

Her sorgu için doğru fetchPolicy seçimi performans açısından kritik. Varsayılan cache-first‘ın yanı sıra şu seçenekler var:

  • cache-first: Önce önbellektir, yoksa network. Çoğu senaryo için ideal.
  • network-only: Her zaman network isteği yapar, önbelleği yazar ama okumaz. Kritik güncel veri için.
  • cache-only: Sadece önbellek okur, network isteği yapmaz. Offline senaryoları için.
  • no-cache: Network isteği yapar, önbelleğe yazmaz. Hassas veriler için.
  • cache-and-network: Önce önbellekten gösterir, arka planda network isteği yapar, gelince günceller. Dashboard gibi senaryolar için.
// Kullanıcı profili - önbellek yeterli
const { data: profile } = useQuery(GET_PROFILE, {
  fetchPolicy: 'cache-first',
  nextFetchPolicy: 'cache-only', // Sonraki sorgularda sadece önbellek
});

// Anlık stok bilgisi - her zaman taze veri
const { data: stock } = useQuery(GET_STOCK_INFO, {
  fetchPolicy: 'network-only',
  pollInterval: 30000, // 30 saniyede bir yenile
});

// Dashboard - hızlı göster, sonra güncelle
const { data: analytics } = useQuery(GET_ANALYTICS, {
  fetchPolicy: 'cache-and-network',
});

Sonuç

Apollo Client’ın önbellekleme sistemi ilk bakışta karmaşık görünüyor ama mantığını kavradıktan sonra ne kadar güçlü olduğunu anlıyorsunuz. Normalize edilmiş önbellek sayesinde veri tutarlılığı, field policies sayesinde veri dönüşümü, optimistic UI sayesinde çok daha iyi kullanıcı deneyimi elde ediyorsunuz.

Benim önerim şu sırayla ilerlemek: önce typePolicies ile normalize etmeyi doğru kurun, sonra sayfalandırma stratejinizi belirleyin, en son optimistic UI’ı ekleyin. Sıralamayı atlayıp direkt optimistic UI’dan başlarsanız önbellek tutarsızlıkları sizi çıldırtabilir.

cache.modify() yerine refetchQueries kullanmak cazip geliyor çünkü daha basit, ama her mutation sonrası network isteği yapmak hem sunucuyu yoruyor hem kullanıcı deneyimini olumsuz etkiliyor. Bir süre sonra fark yaratıyor. Production’da bunu ölçerseniz göreceksiniz.

Son olarak: Apollo DevTools’u kurmayı unutmayın. Önbellekte ne olduğunu, hangi sorguların ne zaman çalıştığını görmek, özellikle hata ayıklarken hayat kurtarıyor.

Bir yanıt yazın

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