React Query'siz: Kendi Data-Fetching Hook'larınızı Yazmak
React ekosisteminde sunucu verisi çekmek denince akla hemen TanStack Query (react-query) geliyor — haklı bir sebeple, olgun bir kütüphane. Ama her proje onun sunduğu tüm karmaşıklığı (cache invalidation stratejileri, stale-while-revalidate, query key normalizasyonu) hak etmiyor. Bu yazıda, bu sitenin ve admin panelinin react-query bağımlılığını tamamen kaldırıp yerine ~80 satırlık, sıfır bağımlılıklı bir çözümle nasıl değiştirdiğimi anlatıyorum.
Neden kaldırdım
İki ayrı Next.js/Vite uygulamasında react-query kuruluydu, ikisi de temelde aynı üç şeyi yapıyordu: bir GET isteği at, data/isLoading/isError durumunu yönet, bir mutasyondan sonra listeyi yenile. QueryClient, staleTime ayarları, cache key normalizasyonu gibi react-query'nin asıl gücü olan özellikler bu ölçekte (kişisel bir site + küçük bir admin panel) hiç kullanılmıyordu — sadece paket boyutuna ve öğrenme yüküne katkı sağlıyorlardı.
Tasarım: iki küçük, framework-agnostik hook
Amaç şuydu: useQuery/useMutation'ın sunduğu ergonomiyi (data/isLoading/isError, mutate/mutateAsync) koru, ama cache/invalidation makinesini at.
// packages/api-hooks/src/useApiQuery.ts
export function useApiQuery<T>(key: string | null, fetcher: () => Promise<T>) {
const [tick, setTick] = useState(0);
const [state, setState] = useState<{ data: T | undefined; isLoading: boolean; isError: boolean }>({
data: undefined,
isLoading: key !== null,
isError: false,
});
useEffect(() => {
if (key === null) return;
let cancelled = false;
setState((prev) => ({ ...prev, isLoading: true, isError: false }));
fetcher()
.then((data) => { if (!cancelled) setState({ data, isLoading: false, isError: false }); })
.catch(() => { if (!cancelled) setState({ data: undefined, isLoading: false, isError: true }); });
return () => { cancelled = true; };
}, [key, tick]);
return { ...state, refetch: () => setTick((t) => t + 1) };
}
Burada dikkat edilmesi gereken üç detay var:
keyhem effect bağımlılığı hem de "atla" sinyali.nullgeçirmek isteği tamamen atlıyor — örneğin henüz bilinmeyen bir slug parametresine bağlı bir sorguyu koşullu çalıştırmak için.cancelledflag'i, yarış koşullarına karşı. Kullanıcı hızlıca farklı bir sayfaya/kategoriye geçtiğinde, eski isteğin cevabı geç gelip state'i "eski" veriyle ezmesin diye cleanup fonksiyonucancelled = trueyapıyor.fetcherbir closure, URL değil. Bu, hook'u tamamen transport-agnostik yapıyor — çağıran taraf ister düzfetch, ister auth header'lı özel birapi()wrapper'ı kullansın, hook'un haberi bile olmuyor.
Mutasyon tarafı da benzer şekilde minimal:
// packages/api-hooks/src/useApiMutation.ts
export function useApiMutation<TInput, TOutput>(
mutationFn: (input: TInput) => Promise<TOutput>,
options?: { onSuccess?: (data: TOutput, input: TInput) => void },
) {
const [isPending, setIsPending] = useState(false);
const [isError, setIsError] = useState(false);
const [error, setError] = useState<unknown>(null);
async function mutateAsync(input: TInput): Promise<TOutput> {
setIsPending(true);
setIsError(false);
try {
const result = await mutationFn(input);
options?.onSuccess?.(result, input);
return result;
} catch (err) {
setIsError(true);
setError(err);
throw err;
} finally {
setIsPending(false);
}
}
function mutate(input: TInput) {
mutateAsync(input).catch(() => {});
}
return { mutate, mutateAsync, isPending, isError, error };
}
Cache invalidation olmadan "tazelik" nasıl sağlanıyor?
React-query'nin en çok satan özelliği queryClient.invalidateQueries() — bir mutasyondan sonra ilgili tüm sorguları otomatik yeniden çalıştırmak. Bizim çözümümüzde global bir query cache olmadığı için bu otomatik olamıyor; bilinçli bir tasarım kararı bu: her liste sayfası kendi refetch'ini sahipleniyor.
// apps/admin/src/routes/projects-list.tsx
const { data: projects, isLoading, isError, refetch } = useProjects();
const deleteProject = useDeleteProject(refetch);
useDeleteProject bir onSuccess callback'i kabul ediyor; liste sayfası kendi refetch'ini oraya veriyor. Silme başarılı olduğunda sadece o an ekranda olan liste yenileniyor — global bir "tüm projects key'li sorguları geçersiz kıl" mekanizmasına gerek kalmıyor, çünkü zaten global bir key sistemi yok.
İki uygulama, iki farklı transport — tek paylaşılan state makinesi
Asıl "kod tekrarını önleme" kısmı burada devreye giriyor. apps/web kimliksiz, düz fetch kullanıyor:
// apps/web/src/lib/queries/projects.queries.ts
export function useProjects(params: ProjectsParams = {}) {
const url = buildProjectsUrl(params);
return useApiQuery(url, () => fetchJson(url, paginatedProjectsSchema));
}
apps/admin ise Bearer token header'ı ekleyen, 401'de otomatik logout yapan kendi api() wrapper'ını kullanıyor:
// apps/admin/src/lib/queries/projects.queries.ts
export function useProjects() {
return useApiQuery("projects", async () => {
const page = paginatedProjectsSchema.parse(await api<PaginatedProjects>(`/projects?pageSize=100&sort=az`));
return page.items;
});
}
İkisi de aynı @myself/api-hooks paketinden useApiQuery'i import ediyor. Tekrar eden kısım (loading/error state yönetimi, cleanup, tick tabanlı refetch) paylaşılan pakette tek yerde yaşıyor; transport farkı (auth var/yok) her uygulamanın kendi fetcher closure'ında kalıyor. Bu ayrım önemli: state makinesini paylaşmak mantıklı çünkü ikisi de birebir aynı; transport'u paylaşmaya çalışmak yanlış soyutlama olurdu, çünkü admin'in auth+401 mantığı web'de anlamsız.
Ne zaman bu yaklaşımı seçmemeli
Bu çözüm bilinçli olarak react-query'nin sunduğu şunlardan vazgeçiyor: arka planda otomatik yeniden çekme, staleTime/gcTime tabanlı akıllı cache, aynı key'e yapılan eşzamanlı isteklerin deduplication'ı, optimistic update yardımcıları. Çok sayfalı, çapraz bileşenler arası veri paylaşımının yoğun olduğu, gerçek zamanlı senkronizasyon gerektiren büyük bir uygulamada bu özelliklerin yeniden inşa edilmesi maliyeti react-query'nin paket boyutunu aşar. Ama bir kişisel site + küçük bir admin panelinde, her sayfa kendi verisini bir kere çekip gösteriyorsa, ~80 satırlık bu iki hook yeterinden fazlası.
Sonuç
"Bağımlılık ekleme" her zaman doğru cevap değil, özellikle de o bağımlılığın çözdüğü problemlerin çoğu sizin ölçeğinizde hiç var olmuyorsa. Kod tekrarını önlemenin yolu her zaman "daha güçlü bir kütüphane kullan" değil — bazen doğru soyutlama seviyesini bulup (burada: state makinesi paylaşılsın, transport paylaşılmasın) 80 satırlık kendi çözümünüzü iki uygulama arasında paylaşmak.