چگونه React Compiler با memoization، ما را از useMemo و useCallback بی‌نیاز می‌کند

اگه چند وقتیه با ری‌اکت کار می‌کنی، حتماً این صحنه رو دیدی: یه کامپوننت ساده می‌نویسی، بعد یه نفر تو ریویو می‌گه «این تابع رو بپیچ تو useCallback، وگرنه هر رندر یه نسخه‌ی جدید ازش ساخته می‌شه». یه مدت بعد کل پروژه پر شده از useMemo و useCallback و آرایه‌های وابستگی که نصفشون رو هیچ‌کس دقیق نمی‌فهمه چرا اونجاست.

خبر خوب اینه که React Compiler اومده تا این کار کثیف رو به‌جای ما انجام بده. تو این مقاله خودمونی و قدم‌به‌قدم می‌بینیم memoization دقیقاً چیه، کامپایلر چطور کارش رو انجام می‌ده، چطور راه‌اندازیش کنیم و مهم‌تر از همه: کجاها هنوز هم به useMemo و useCallback نیاز داریم.

بدون memoization Parent (state عوض شد) Child A Child B Child C هر سه تا دوباره رندر شدن حتی اگه props هیچ‌کدوم عوض نشده باشه با React Compiler Parent (state عوض شد) Child A Child B ✓ cache Child C ✓ cache فقط اونی که واقعاً عوض شده رندر می‌شه بقیه از cache برمی‌گردن
شکل ۱: تفاوت رندر مجدد بدون memoization و با کامپایلر

اصلاً memoization یعنی چی؟

Memoization یه ترفند قدیمیِ بهینه‌سازیه: نتیجه‌ی یه محاسبه رو نگه می‌داریم، و اگه دفعه‌ی بعد ورودی‌ها همون قبلی بودن، به‌جای محاسبه‌ی دوباره، همون نتیجه‌ی ذخیره‌شده رو برمی‌گردونیم. مثل این می‌مونه که جواب یه مسئله‌ی سخت رو یه جا یادداشت کنی تا دفعه‌ی بعد دوباره حلش نکنی.

تو ری‌اکت یه نکته‌ی مهم وجود داره: هر بار که state یه کامپوننت عوض می‌شه، کل تابع کامپوننت دوباره اجرا می‌شه. یعنی هر چی داخلش تعریف کردی (مقدارها، آبجکت‌ها، تابع‌ها) از نو ساخته می‌شه. برای همین ری‌اکت دو تا هوک بهمون داد تا دستی جلوش رو بگیریم:

  • useMemo: نتیجه‌ی یه محاسبه‌ی سنگین رو نگه می‌داره.
  • useCallback: یه نسخه‌ی ثابت از تابع رو بین رندرها نگه می‌داره.

قبل از کامپایلر: دستی بهینه می‌کردیم

یه نمونه‌ی کلاسیک ببین:

jsx
import { useState, useMemo, useCallback } from "react";

function ExpensiveComponent({ num }) {
  const expensiveValue = useMemo(() => {
    console.log("Computing...!");
    return num * 2;
  }, [num]);

  const handleClick = useCallback(() => {
    console.log("Button clicked!");
  }, []);

  return (
    <div>
      <p>Computed Value: {expensiveValue}</p>
      <button onClick={handleClick}>Click Me</button>
    </div>
  );
}

اینجا useMemo جلوی محاسبه‌ی دوباره‌ی expensiveValue رو می‌گیره و useCallback هم باعث می‌شه handleClick هر رندر یه تابع جدید نباشه. تا اینجا خوبه، ولی مشکلش چیه؟

  • کد شلوغ می‌شه و خوندنش سخت‌تر.
  • باید آرایه‌ی وابستگی رو دستی و دقیق بنویسی. یه چیزی جا بندازی، باگ‌های عجیب می‌گیری (مثلاً مقدار قدیمی، یا لوپ بی‌نهایت).
  • خیلی وقت‌ها از سر احتیاط همه‌چیز رو memo می‌کنیم، بدون اینکه اندازه گرفته باشیم واقعاً لازمه یا نه.
رندر شماره ۱ handleClick = fn#1 رندر شماره ۲ handleClick = fn#2 رندر شماره ۳ handleClick = fn#3 fn#1 !== fn#2 !== fn#3 برای ری‌اکت هر کدوم یه prop «جدیده»، پس child رو دوباره رندر می‌کنه
شکل ۲: چرا تابع‌های جدید باعث رندر مجدد child می‌شن

React Compiler وارد می‌شه

React Compiler یه کامپایلره که موقع build کد کامپوننت‌هات رو تحلیل می‌کنه و خودش می‌فهمه چه چیزهایی به چه چیزهایی وابسته‌ست. بعد، کد رو طوری بازنویسی می‌کنه که مقدارها و تابع‌ها فقط وقتی دوباره ساخته بشن که ورودی‌هاشون واقعاً عوض شده باشه. یعنی همون کاری که تو دستی با useMemo و useCallback می‌کردی، ولی خودکار و (معمولاً) دقیق‌تر.

کامپایلر در عمل این کارها رو می‌کنه:

  • جلوی رندرهای بی‌مورد رو می‌گیره.
  • محاسبه‌های سنگین رو خودکار memoize می‌کنه.
  • رفرنس تابع‌ها رو ثابت نگه می‌داره تا propها بی‌دلیل عوض نشن.

پس همون کامپوننت بالا رو می‌تونی این‌جوری بنویسی:

jsx
function ExpensiveComponent({ num }) {
  function computeValue() {
    console.log("Computing...!");
    return num * 2;
  }

  function handleClick() {
    console.log("Button clicked!");
  }

  return (
    <div>
      <p>Computed Value: {computeValue()}</p>
      <button onClick={handleClick}>Click Me</button>
    </div>
  );
}

هیچ هوک اضافه‌ای نیست، آرایه‌ی وابستگی نیست و کد دقیقاً همون چیزیه که تو ذهنت داشتی. کامپایلر پشت صحنه کارش رو می‌کنه.

یه توضیح مهم: خیلی از مقاله‌ها می‌گن «ری‌اکت ۱۹ خودش خودکار memoize می‌کنه». دقیق‌تر اینه که React Compiler یه ابزار جداست (یه پلاگین Babel) که با ری‌اکت ۱۹ خیلی خوب کار می‌کنه و باید خودت تو پروژه فعالش کنی. به‌صورت پیش‌فرض و بدون هیچ تنظیمی، فقط با آپدیت ری‌اکت اتفاقی نمی‌افته.

کامپایلر پشت صحنه چی کار می‌کنه؟

کد ساده‌ی تو بدون هوک memo React Compiler تحلیل وابستگی‌ها کد بهینه‌شده با cache خودکار مرورگر رندر سریع‌تر موقع build اتفاق می‌افته، نه تو مرورگر کاربر
شکل ۳: مسیر کد از نوشتن تا اجرا

کامپایلر کدت رو به یه نمایش داخلی تبدیل می‌کنه، جریان داده‌ها رو دنبال می‌کنه و برای هر مقدار می‌فهمه به کدوم propها و stateها بستگی داره. بعدش یه «کش» کوچیک برای هر کامپوننت می‌سازه. خروجیِ تقریبی (ساده‌شده برای فهم بهتر) یه چیزی شبیه این می‌شه:

jsx
import { c as _c } from "react/compiler-runtime";

function ExpensiveComponent({ num }) {
  const $ = _c(4); // یه آرایه‌ی کش برای این کامپوننت

  let computed;
  if ($[0] !== num) {
    // num عوض شده، پس دوباره حساب کن
    computed = num * 2;
    $[0] = num;
    $[1] = computed;
  } else {
    // num همونه، از کش بخون
    computed = $[1];
  }

  let jsx;
  if ($[2] !== computed) {
    jsx = <p>Computed Value: {computed}</p>;
    $[2] = computed;
    $[3] = jsx;
  } else {
    jsx = $[3];
  }

  return jsx;
}

دیدی؟ همون منطق «اگه ورودی عوض نشده، از قبلی استفاده کن» ولی کامپایلر برات نوشته. نکته‌ی جالبش اینه که کامپایلر می‌تونه حتی بخش‌هایی از JSX رو هم کش کنه، چیزی که با useMemo دستی خیلی اذیت‌کننده بود.

رندر جدید شروع شد ورودی‌ها عوض شدن؟ آره نه دوباره محاسبه کن و تو کش بذار از کش بخون (هزینه‌ی تقریباً صفر) نتیجه رو برگردون
شکل ۴: منطقی که کامپایلر برای هر مقدار اجرا می‌کنه

مثال واقعی‌تر: لیست محصولات با فیلتر

مثال بالا برای فهمیدن خوبه، ولی تو پروژه‌ی واقعی بیشتر با چیزی شبیه این درگیری. یه لیست محصول که فیلتر می‌شه و به یه کامپوننت React.memo پاس داده می‌شه. اینجوری قبلاً می‌نوشتیم:

jsx
import { memo, useCallback, useMemo, useState } from "react";

const ProductCard = memo(function ProductCard({ product, onAdd }) {
  return (
    <li>
      {product.title}
      <button onClick={() => onAdd(product.id)}>افزودن</button>
    </li>
  );
});

function ProductList({ products }) {
  const [query, setQuery] = useState("");
  const [cart, setCart] = useState([]);

  const visibleProducts = useMemo(
    () => products.filter((p) => p.title.includes(query)),
    [products, query]
  );

  const handleAdd = useCallback((id) => {
    setCart((prev) => [...prev, id]);
  }, []);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>تعداد سبد: {cart.length}</p>
      <ul>
        {visibleProducts.map((p) => (
          <ProductCard key={p.id} product={p} onAdd={handleAdd} />
        ))}
      </ul>
    </>
  );
}

و با کامپایلر، همین کد می‌شه:

jsx
function ProductCard({ product, onAdd }) {
  return (
    <li>
      {product.title}
      <button onClick={() => onAdd(product.id)}>افزودن</button>
    </li>
  );
}

function ProductList({ products }) {
  const [query, setQuery] = useState("");
  const [cart, setCart] = useState([]);

  const visibleProducts = products.filter((p) => p.title.includes(query));

  function handleAdd(id) {
    setCart((prev) => [...prev, id]);
  }

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>تعداد سبد: {cart.length}</p>
      <ul>
        {visibleProducts.map((p) => (
          <ProductCard key={p.id} product={p} onAdd={handleAdd} />
        ))}
      </ul>
    </>
  );
}

دیدی چقدر تمیزتر شد؟ نه memo، نه useMemo، نه useCallback. کامپایلر می‌فهمه handleAdd به هیچ مقدار متغیری وابسته نیست (چون از setCart با تابع آپدیت استفاده می‌کنه)، پس رفرنسش رو ثابت نگه می‌داره. هر بار که کاربر تو input تایپ می‌کنه، فقط کارت‌هایی دوباره رندر می‌شن که واقعاً داده‌شون عوض شده.

چطور React Compiler رو تو پروژه فعال کنیم؟

اول پلاگین رو نصب کن:

bash
npm install -D babel-plugin-react-compiler

بعد بسته به ابزاری که استفاده می‌کنی، پلاگین رو به Babel اضافه کن. مثلاً تو Vite:

js
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler"]],
      },
    }),
  ],
});

اگه از Next.js استفاده می‌کنی، نسخه‌های جدیدش یه گزینه‌ی تنظیمات برای روشن کردن کامپایلر دارن. بهتره دقیقاً از داکیومنت نسخه‌ای که نصب داری ببینی، چون اسم و محل این گزینه بین نسخه‌ها فرق کرده:

js
// next.config.js
const nextConfig = {
  reactCompiler: true,
};

module.exports = nextConfig;

اگه هنوز ری‌اکت ۱۹ نداری، کامپایلر با ۱۷ و ۱۸ هم کار می‌کنه، فقط باید پکیج react-compiler-runtime رو هم نصب کنی و نسخه‌ی هدف رو تو تنظیمات پلاگین مشخص کنی. جزئیاتش تو داکیومنت رسمی هست.

کم‌کم شروع کن، نه یهویی

لازم نیست کل پروژه رو یک‌شبه بدی دست کامپایلر. می‌تونی فقط کامپوننت‌هایی رو که خودت علامت زدی کامپایل کنی:

js
// babel config
{
  plugins: [
    ["babel-plugin-react-compiler", { compilationMode: "annotation" }],
  ],
}
jsx
function HeavyChart({ data }) {
  "use memo"; // فقط این کامپوننت کامپایل می‌شه

  // ...
}

و برعکس، اگه یه کامپوننت باهاش مشکل داشت، می‌تونی از کامپایل کنارش بذاری:

jsx
function LegacyWidget({ value }) {
  "use no memo"; // کامپایلر این کامپوننت رو دست نمی‌زنه

  // ...
}

پس دیگه اصلاً useMemo و useCallback لازم نیست؟

تو بیشتر کدهای جدید، نه. ولی «بیشتر» با «همیشه» فرق داره. چند تا جا هست که هنوز بهشون نیاز داری یا حداقل بهتره نگهشون داری:

آیا هنوز باید دستی memo کنم؟ useCallback / useMemo تابع یا مقدار تو dependency یه useEffect هست و رفتار اون افکت به ثبات رفرنس بستگی داره کتابخونه‌ی خارجی کتابخونه ثبات رفرنس propها رو انتظار داره و خودش از کامپایلر خبر نداره کامپایلر جا زد (bail out) کد از قواعد React تخطی می‌کنه و کامپایلر اون تابع رو رد می‌کنه بقیه‌ی موارد کد ساده بنویس و بذار کامپایلر کارش رو بکنه اول اندازه بگیر، بعد بهینه کن
شکل ۵: چهار حالتی که باید حواست بهشون باشه

۱. وقتی تابع یا مقدار توی dependency یه useEffect هست

اگه یه افکت داری که با عوض شدن رفرنس یه تابع دوباره اجرا می‌شه و منطق‌ت به این رفتار وابسته‌ست، خیلی امن‌تره که ثبات اون رفرنس رو خودت صریحاً تضمین کنی. کامپایلر ممکنه memoization رو حفظ کنه، ولی کنترلش دست تو نیست و فقط وقتی «بهینه‌سازی» حساب می‌شه که کامپایلر روشنه:

jsx
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  // رفرنس این تابع باید فقط وقتی query عوض می‌شه، عوض بشه
  const fetchResults = useCallback(async () => {
    const res = await fetch(`/api/search?q=${query}`);
    setResults(await res.json());
  }, [query]);

  useEffect(() => {
    fetchResults();
  }, [fetchResults]);

  return <ResultList items={results} />;
}

اینجا useCallback فقط برای «سرعت» نیست، بخشی از منطق برنامه‌ست. اگه کامپایلر رو خاموش کنی یا اون تابع کامپایل نشه، بدون این هوک افکتت هر رندر دوباره اجرا می‌شه.

۲. وقتی با کتابخونه‌ی خارجی کار می‌کنی

بعضی کتابخونه‌ها (مثلاً چیزی که یه آبجکت config می‌گیره و با عوض شدنش خودش رو ریست می‌کنه) انتظار دارن مقدارهایی که می‌دی ثابت باشن. اگه مطمئن نیستی کامپایلر اون مقدار رو ثابت نگه می‌داره، useMemo رو نگه دار:

jsx
function MapView({ center, zoom }) {
  const options = useMemo(
    () => ({ center, zoom, controls: false }),
    [center, zoom]
  );

  return <ThirdPartyMap options={options} />;
}

۳. وقتی کامپایلر جا می‌زنه

کامپایلر فرض می‌کنه کدت از «قواعد ری‌اکت» پیروی می‌کنه: کامپوننت‌ها pure باشن، stateها مستقیم mutate نشن، و هوک‌ها شرطی صدا زده نشن. اگه یه تابعی این قواعد رو بشکنه، کامپایلر ترجیح می‌ده اون تابع رو دست نزنه تا کدت خراب نشه. مثلاً:

jsx
function BadExample({ items }) {
  // ❌ دارم prop رو مستقیم تغییر می‌دم (mutation)
  items.push({ id: "new" });

  return <List items={items} />;
}

کدی مثل بالا یا اصلاً کامپایل نمی‌شه یا رفتار غیرمنتظره می‌گیره. راه درستش همون کد pure معمولیه:

jsx
function GoodExample({ items }) {
  // ✅ یه کپی جدید می‌سازیم، بدون تغییر دادن prop
  const nextItems = [...items, { id: "new" }];

  return <List items={nextItems} />;
}

خوبه که ESLint ری‌اکت رو هم فعال کنی؛ نسخه‌های جدید پلاگین هوک‌های ری‌اکت، قواعدی دارن که همین جور مشکل‌ها رو قبل از اجرا بهت نشون می‌دن.

۴. وقتی واقعاً محاسبه‌ی خیلی سنگین داری

اگه یه محاسبه‌ی خیلی خیلی سنگین داری (مثلاً پردازش یه آرایه‌ی صدهزارتایی) و دقیقاً می‌خوای تضمین کنی فقط وقتی ورودی عوض شد اجرا بشه، می‌تونی useMemo رو صریح بنویسی تا نیتت تو کد معلوم باشه. فقط یادت باشه کامپایلر هم ممکنه همین کار رو انجام بده، پس حتماً قبل و بعد رو با Profiler اندازه بگیر.

نکته‌ی مهم برای پروژه‌های موجود

اگه پروژه‌ات پر از useMemo و useCallbackه، لازم نیست همه رو پاک کنی. کامپایلر با اونا هم کار می‌کنه و حتی اگه یکی‌شون رو بی‌فایده تشخیص بده، می‌تونه کنارش بذاره. پیشنهاد عملی من اینه:

  1. اول کامپایلر رو فعال کن و ببین پروژه سالم بالا میاد یا نه.
  2. برای کدهای جدید، دیگه هوک‌های memo رو پیش‌فرض ننویس.
  3. توی کدهای قدیمی، اونایی رو که مطمئنی فقط برای بهینه‌سازی بودن، کم‌کم و با تست بردار.
  4. اونایی که تو dependency افکت‌ها هستن یا به کتابخونه‌ی خارجی پاس داده می‌شن رو فعلاً دست نزن.

بهترین روش‌ها و اشتباه‌های رایج

✅ چیزهایی که باید انجام بدی

  • اول کد ساده بنویس و بذار کامپایلر بهینه‌ش کنه.
  • useMemo و useCallback رو فقط وقتی واقعاً لازم شد استفاده کن.
  • قبل از بهینه‌سازی، با React DevTools Profiler اندازه بگیر.
  • ESLint رو فعال کن تا تخطی از قواعد ری‌اکت رو سریع بگیره.

❌ اشتباه‌هایی که زیاد می‌بینم

  • همه‌چیز رو بی‌دلیل useMemo و useCallback کردن و کد رو پیچیده کردن.
  • فرض کردن اینکه «همه‌چیز باید memo بشه» بدون اینکه اندازه گرفته باشی.
  • فکر کردن اینکه کامپایلر بدون فعال کردن و تنظیم، خودبه‌خود کار می‌کنه.
  • نوشتن کدی که props یا state رو مستقیم تغییر می‌ده و بعد تعجب از اینکه کامپایلر کنارش گذاشته.

جمع‌بندی

React Compiler یکی از بهترین اتفاق‌هایی بود که برای تجربه‌ی نوشتن کد ری‌اکت افتاد. کدها ساده‌تر، خواناتر و نگهداری‌شون راحت‌تر می‌شه، و به‌جای اینکه حواست به آرایه‌های وابستگی باشه، می‌تونی روی خود منطق برنامه تمرکز کنی.

ولی حواست باشه که جادو نیست. کامپایلر به قواعد ری‌اکت تکیه می‌کنه، تو بعضی موقعیت‌ها جا می‌زنه، و تو جاهایی مثل dependency افکت‌ها و کتابخونه‌های خارجی هنوز ممکنه به useMemo و useCallback نیاز داشته باشی. قاعده‌ی طلایی همون چیزیه که اول مقاله گفتیم: اول ساده بنویس، بعد اندازه بگیر، و فقط جایی دستی دخالت کن که واقعاً لازمه.

تو پروژه‌ی خودت کامپایلر رو امتحان کردی؟ اگه تجربه‌ای داشتی یا جایی ازش گیر کردی، حتماً بگو. 😊

منابع