@sedatdemirAra

React Compiler: useMemo ve useCallback silinebilir mi?

Geçen ay orta büyüklükte bir React 19 projesinde React Compiler v1.0'ı açtım. İlk iş olarak 140 civarı useMemo ve useCallback çağrısını sildim. Testler geçti, Lighthouse skorları aynı kaldı, hatta bazı sayfalarda interaction-to-next-paint (INP) 15-20 ms düştü. Sonra bir hafta içinde iki bug buldum: ikisi de compiler'ın sessizce devre dışı kaldığı bileşenlerden geliyordu.

Bu yazı, o süreçte öğrendiklerimi aktarıyor: hangi durumlarda elle yazılmış memoization'ı güvenle silebilirsiniz, hangi durumlarda silemezsiniz, compiler'ın sınırlarını nasıl tespit edersiniz.

Compiler ne yapıyor, ne yapmıyor

React Compiler (eski adıyla React Forget), build zamanında bileşen ve hook kodunu analiz edip otomatik memoization ekliyor. Babel plugin olarak çalışıyor. Çıktıda useMemo, useCallback ve hatta JSX düğümlerinin kendileri için cache slotları oluşturuyor. Yani elle yazdığınız useMemo(() => expensiveCalc(a, b), [a, b]) ifadesinin yaptığı işi, compiler sizin yerinize ve genellikle daha ince granülerlikte yapıyor.

Ama compiler bunu her bileşende yapmıyor. Bir bileşen "Rules of React"i ihlal ediyorsa compiler o bileşeni atlıyor. Hata vermiyor, uyarı fırlatmıyor (varsayılan ayarlarda). Sessizce geçiyor. İşte bu sessizlik en tehlikeli kısım.

Rules of React: compiler'ın ön koşulları

Compiler'ın doğru çalışması için kodun şu kurallara uyması gerekiyor:

  1. Render sırasında yan etki üretmemek (DOM manipülasyonu, global state mutasyonu, console.log bile teknik olarak ihlal)
  2. Props ve state'i immutable tutmak
  3. Hook kurallarına uymak (koşullu hook çağrısı yok)
  4. Render fonksiyonunun aynı girdiler için aynı JSX'i döndürmesi (idempotent olması)

Bunlar zaten React'in beklediği kurallar. Fark şu: eskiden bu kuralları çiğnediğinizde çoğu zaman uygulama "tesadüfen" çalışıyordu çünkü React her render'da her şeyi yeniden hesaplıyordu. Compiler memoization eklediğinde ise bozuk kod gerçekten bozuluyor, çünkü artık bazı hesaplamalar atlanıyor.

Compiler'ın sessizce devre dışı kaldığını nasıl anlarsınız

React Compiler Babel plugin'i compilationMode: 'all' (varsayılan) ayarıyla çalıştığında, derleyemediği bileşenleri es geçer. Bunu tespit etmenin iki yolu var.

Birincisi, eslint-plugin-react-compiler kuralı. Bu lint kuralı, compiler'ın atlayacağı ihlalleri build öncesinde yakalar:

npm install -D eslint-plugin-react-compiler
// eslint.config.js (flat config)
import reactCompiler from 'eslint-plugin-react-compiler';

export default [
  {
    plugins: {
      'react-compiler': reactCompiler,
    },
    rules: {
      'react-compiler/react-compiler': 'error',
    },
  },
];

Bu kuralı 'error' seviyesinde tutmanızı öneririm. 'warn' yaparsanız CI'da gözden kaçıyor.

İkincisi, React DevTools. React DevTools v5.3+ sürümünde bileşen ağacında compiler tarafından optimize edilen bileşenler "Memo ✨" badge'i alıyor. Badge olmayan bileşenler ya zaten optimize edilmeye gerek duyulmamış ya da compiler tarafından atlanmış. DevTools'un Profiler sekmesindeki "Why did this render?" bilgisi de hâlâ çalışıyor.

Gerçek projede karşılaştığım iki sorun

Sorun 1: render içinde mutasyon

Bir bileşende şöyle bir pattern vardı:

function UserList({ users }: { users: User[] }) {
  const sorted = users.sort((a, b) => a.name.localeCompare(b.name));
  return (
    <ul>
      {sorted.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Array.prototype.sort diziyi yerinde (in-place) sıralar. Bu, prop olarak gelen users dizisini mutasyona uğratıyor. Compiler bu bileşeni atlıyordu. Lint kuralı da "Mutating component props or hook arguments is not allowed" hatası veriyordu ama proje eski olduğu için lint henüz eklenmemişti.

Düzeltme basit:

const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name));

Bu düzeltmeden sonra compiler bileşeni optimize etti ve elle yazılmış useMemo'ya gerek kalmadı.

Sorun 2: ref callback içinde DOM ölçümü ve state güncellemesi

Bir bileşen, ref callback içinde element boyutunu ölçüp state'e yazıyordu:

function MeasuredBox({ children }: { children: React.ReactNode }) {
  const [height, setHeight] = useState(0);

  return (
    <div
      ref={(el) => {
        if (el) setHeight(el.getBoundingClientRect().height);
      }}
    >
      {children}
      <span>Height: {height}px</span>
    </div>
  );
}

Burada render sırasında oluşturulan inline fonksiyon her render'da yeni bir referans. Compiler bunu memoize etmeye çalışıyor ama setHeight çağrısı layout'a bağımlı bir yan etki. Compiler bu bileşeni optimize edebiliyor (çünkü teknik olarak callback render sırasında çağrılmıyor, mount/update sırasında çağrılıyor), fakat memoize ettiğinde ref callback referansı değişmediği için React onu tekrar çağırmıyor ve yükseklik güncellenmiyordu.

Çözüm olarak useCallback'i elle geri koymak yerine "use no memo" direktifini kullandım:

function MeasuredBox({ children }: { children: React.ReactNode }) {
  "use no memo";
  const [height, setHeight] = useState(0);

  return (
    <div
      ref={(el) => {
        if (el) setHeight(el.getBoundingClientRect().height);
      }}
    >
      {children}
      <span>Height: {height}px</span>
    </div>
  );
}

"use no memo" direktifi

"use no memo" compiler'ın kaçış kapısı. Bileşen veya hook'un en üstüne eklediğinizde compiler o fonksiyonu tamamen atlıyor. Bu, "use client" ve "use server" ile aynı mekanizma üzerinden çalışıyor.

Bu direktifi iki durumda kullanıyorum:

  • Compiler'ın memoization'ı davranışı bozduğunda (yukarıdaki ref callback örneği gibi)
  • Üçüncü parti bir kütüphanenin iç yapısıyla etkileşen ve kuralları kasıtlı olarak çiğneyen bileşenlerde (canvas rendering, D3 entegrasyonu gibi)

Diğer her durumda compiler'a güvenip elle memoization yazmamak daha iyi.

Adım adım geçiş stratejisi

Bir projede compiler'ı açıp tüm useMemo/useCallback'leri aynı PR'da silmek riskli. Benim uyguladığım strateji şuydu:

1. adım: lint kuralını ekle

eslint-plugin-react-compiler kuralını error seviyesinde ekleyip tüm ihlalleri düzelt. Bu adımda compiler'ı henüz açmıyorsun. Sadece kodun kurallara uygun olduğundan emin oluyorsun.

2. adım: compiler'ı annotation modunda aç

Babel config'de compilationMode: 'annotation' kullanarak sadece "use memo" direktifi olan bileşenleri derle:

// babel.config.js
const ReactCompilerConfig = {
  compilationMode: 'annotation',
};

module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', ReactCompilerConfig],
  ],
};

Bu modda birkaç kritik bileşende "use memo" ekleyip test edebilirsin.

3. adım: compiler'ı tüm projeye aç

compilationMode: 'all' (varsayılan) ile tüm bileşenleri derle. Bu noktada elle yazılmış useMemo/useCallback çağrıları hâlâ yerinde durabilir. Compiler onları görmezden gelmiyor, kendi memoization'ını onların üzerine ekliyor. Yani çift memoization oluyor ama bu bir hata değil, sadece gereksiz.

4. adım: elle yazılmış memoization'ı sil

Compiler açıkken ve testler geçiyorken useMemo/useCallback çağrılarını kaldır. Her kaldırma sonrasında ilgili sayfanın INP metriklerini kontrol et.

useMemo/useCallback tamamen gereksiz mi oldu

Hayır. İki durum var:

Birincisi, compiler'ın devre dışı kaldığı bileşenler. "use no memo" ile opt-out ettiysen, o bileşende performans gerekliyse elle memoization hâlâ geçerli.

İkincisi, compiler'ın kapsamı dışındaki hesaplamalar. Compiler yalnızca React bileşen ve hook fonksiyonlarını optimize ediyor. Utility fonksiyonlar, class method'lar, event handler'lar compiler'ın radarında değil. Ama zaten useMemo/useCallback de bunları kapsamamıştı, dolayısıyla bu bir değişiklik değil.

Bir de React Compiler v1.0 ile otomatik memoization yazısında compiler'ın iç mekanizmasını daha detaylı incelemiştim, kurulum adımları için oraya bakabilirsiniz.

Performans ölçümlerinden gözlemler

140 useMemo/useCallback silindikten sonra:

  • Bundle boyutu 2.1 KB (gzipped) azaldı. Her useCallback çağrısı dependency array ile birlikte birkaç düzine byte ediyor, toplamda fark ediyor.
  • İlk render süreleri değişmedi (compiler kendi cache yapısını ekliyor, bu da byte maliyeti taşıyor, ama elle yazılan hook overhead'ı kadar).
  • Re-render süreleri iki sayfada iyileşti. Compiler, elle yazmadığım JSX düğümlerini de memoize ettiği için bazı alt ağaçlar artık atlanıyordu.

Compiler ile React 19 hook'ları

React 19'daki use() hook'u (detaylı inceleme) ve useActionState (form actions yazısı) compiler ile uyumlu çalışıyor. Compiler bu hook'ların dönüş değerlerini de dependency olarak takip edebiliyor.

React Server Components ile birlikte düşündüğünüzde tablo şu: server component'lar zaten her istekte yeniden çalışıyor ve client'a serileştirilmiş JSX gönderiyor. Memoization yalnızca client component'larda anlamlı. Compiler da yalnızca client component'ları derliyor.

Ne zaman elle müdahale etmeli

Compiler'a güvenin, ama doğrulayın. Lint kuralını error seviyesinde tutun. DevTools'ta "Memo ✨" badge'ini kontrol edin. "use no memo" direktifini bir geçici çözüm olarak görün, kalıcı olarak bırakmak yerine altındaki ihlali düzeltmeye çalışın. Ve en önemlisi: compiler'ın bir bileşeni atladığını fark etmezseniz, o bileşendeki performans gerilemesini de fark edemezsiniz. Sessiz başarısızlık, bu aracın en zayıf noktası.