14.01.2026

React’te “UI State” ile “Server State”i Ayırmak: Daha Basit Bileşenler, Daha Az Bug

React’te yerel UI state ile sunucudan gelen state’i ayırarak karmaşıklığı ve gereksiz render’ları azaltın.

React uygulamaları büyüdükçe çoğu sorun tek bir yerden çıkar: aynı state türlerini aynı sepete koymak. Modal açık mı? Seçili tab hangisi? API’den gelen kullanıcı listesi? Hepsi useState/global store’a yığıldığında bileşenler şişer, veri akışı bulanıklaşır.

Bu yazıda basit bir prensip anlatacağım: UI state (geçici, etkileşim odaklı) ile server state (sunucunun kaynağı olduğu, cache’lenebilir) ayrılmalı.

1) UI State vs Server State: Kısa Tanım

UI State (yerel):

  • Modal/drawer açık mı?
  • Formda şifre görünür mü?
  • Seçili satır, pagination sayfası (URL’ye taşımıyorsanız)
  • Hover, focus, optimistic “loading” bayrakları

Server State:

  • Kullanıcı listesi, ürün detayı
  • Sunucudan gelen filtrelenmiş sonuçlar
  • Yetkiler/rol gibi backend kaynaklı gerçekler

Kural: Server state’i useState ile “kopyalama”. Sunucudan gelen veriyi bir kez state’e alıp sonra manuel güncellemek, stale data ve yarış koşullarını (race condition) davet eder.

2) Kötü Koku: Server Verisini Local State’e Kopyalamak

Aşağıdaki yaklaşım sık görülür:

const [users, setUsers] = useState<User[]>([]);

useEffect(() => {
  fetch("/api/users").then(r => r.json()).then(setUsers);
}, []);

Sorunlar:

  • Aynı veri başka yerde de çekilirse senkron kaybolur.
  • “Invalidate/refresh” mantığı her yerde tekrar eder.
  • Bir yerde setUsers ile güncellenir, başka yerde eski kalır.

3) Server State’i Cache’leyen Bir Katmana Verin (TanStack Query Örneği)

Server state için cache + refetch + retry + dedupe gibi ihtiyaçlar doğal. Bu yüzden TanStack Query gibi bir katman çok iş görür.

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function useUsers() {
  return useQuery({
    queryKey: ["users"],
    queryFn: async () => {
      const res = await fetch("/api/users");
      if (!res.ok) throw new Error("Fetch failed");
      return res.json() as Promise<User[]>;
    },
    staleTime: 30_000,
  });
}

Bileşen tarafı sadeleşir:

function UsersPage() {
  const { data: users, isLoading, error } = useUsers();
  const [selectedId, setSelectedId] = useState<string | null>(null); // UI state

  if (isLoading) return <p>Yükleniyor…</p>;
  if (error) return <p>Hata oluştu.</p>;

  return (
    <ul>
      {users!.map(u => (
        <li key={u.id}>
          <button onClick={() => setSelectedId(u.id)}>
            {u.name} {selectedId === u.id ? "(seçili)" : ""}
          </button>
        </li>
      ))}
    </ul>
  );
}

Burada kritik nokta: Seçili kullanıcı UI state, kullanıcı listesi server state.

4) Mutasyon Sonrası: “Elle Düzeltme” Yerine Invalidate

Kullanıcı ekleme örneği:

function useCreateUser() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: async (payload: { name: string }) => {
      const res = await fetch("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (!res.ok) throw new Error("Create failed");
      return res.json() as Promise<User>;
    },
    onSuccess: () => {
      // Server state yeniden doğrulansın
      qc.invalidateQueries({ queryKey: ["users"] });
    },
  });
}

Bu sayede setUsers([...users, newUser]) gibi “kopyayı güncelleme” dertleri azalır.

5) Pratik Bir Checklist

  • Server’dan gelen veriyi mümkünse tek kaynak olarak tut: query cache.
  • UI etkileşimlerini bileşene yakın tut: useState, useReducer.
  • “Loading” bayraklarını ayır:
    • Server: isLoading/isFetching
    • UI: isModalOpen, isSubmitting
  • “Derived state” üretmek için ekstra state açma; gerekirse useMemo kullan.

Sonuç

UI state ve server state’i bilinçli ayırmak; bileşenleri küçültür, veri tutarsızlıklarını azaltır ve debug süresini ciddi biçimde kısaltır. React’te mimariyi büyütmenin en temiz yollarından biri, state’i türüne göre doğru yere koymak.