چگونه React Compiler با memoization، ما را از useMemo و useCallback بینیاز میکند
اگه چند وقتیه با ریاکت کار میکنی، حتماً این صحنه رو دیدی: یه کامپوننت ساده مینویسی، بعد یه نفر تو ریویو میگه «این تابع رو بپیچ تو useCallback، وگرنه هر رندر یه نسخهی جدید ازش ساخته میشه». یه مدت بعد کل پروژه پر شده از useMemo و useCallback و آرایههای وابستگی که نصفشون رو هیچکس دقیق نمیفهمه چرا اونجاست.
خبر خوب اینه که React Compiler اومده تا این کار کثیف رو بهجای ما انجام بده. تو این مقاله خودمونی و قدمبهقدم میبینیم memoization دقیقاً چیه، کامپایلر چطور کارش رو انجام میده، چطور راهاندازیش کنیم و مهمتر از همه: کجاها هنوز هم به useMemo و useCallback نیاز داریم.
اصلاً memoization یعنی چی؟
Memoization یه ترفند قدیمیِ بهینهسازیه: نتیجهی یه محاسبه رو نگه میداریم، و اگه دفعهی بعد ورودیها همون قبلی بودن، بهجای محاسبهی دوباره، همون نتیجهی ذخیرهشده رو برمیگردونیم. مثل این میمونه که جواب یه مسئلهی سخت رو یه جا یادداشت کنی تا دفعهی بعد دوباره حلش نکنی.
تو ریاکت یه نکتهی مهم وجود داره: هر بار که state یه کامپوننت عوض میشه، کل تابع کامپوننت دوباره اجرا میشه. یعنی هر چی داخلش تعریف کردی (مقدارها، آبجکتها، تابعها) از نو ساخته میشه. برای همین ریاکت دو تا هوک بهمون داد تا دستی جلوش رو بگیریم:
useMemo: نتیجهی یه محاسبهی سنگین رو نگه میداره.useCallback: یه نسخهی ثابت از تابع رو بین رندرها نگه میداره.
قبل از کامپایلر: دستی بهینه میکردیم
یه نمونهی کلاسیک ببین:
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 میکنیم، بدون اینکه اندازه گرفته باشیم واقعاً لازمه یا نه.
React Compiler وارد میشه
React Compiler یه کامپایلره که موقع build کد کامپوننتهات رو تحلیل میکنه و خودش میفهمه چه چیزهایی به چه چیزهایی وابستهست. بعد، کد رو طوری بازنویسی میکنه که مقدارها و تابعها فقط وقتی دوباره ساخته بشن که ورودیهاشون واقعاً عوض شده باشه. یعنی همون کاری که تو دستی با useMemo و useCallback میکردی، ولی خودکار و (معمولاً) دقیقتر.
کامپایلر در عمل این کارها رو میکنه:
- جلوی رندرهای بیمورد رو میگیره.
- محاسبههای سنگین رو خودکار memoize میکنه.
- رفرنس تابعها رو ثابت نگه میداره تا propها بیدلیل عوض نشن.
پس همون کامپوننت بالا رو میتونی اینجوری بنویسی:
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) که با ریاکت ۱۹ خیلی خوب کار میکنه و باید خودت تو پروژه فعالش کنی. بهصورت پیشفرض و بدون هیچ تنظیمی، فقط با آپدیت ریاکت اتفاقی نمیافته.
کامپایلر پشت صحنه چی کار میکنه؟
کامپایلر کدت رو به یه نمایش داخلی تبدیل میکنه، جریان دادهها رو دنبال میکنه و برای هر مقدار میفهمه به کدوم propها و stateها بستگی داره. بعدش یه «کش» کوچیک برای هر کامپوننت میسازه. خروجیِ تقریبی (سادهشده برای فهم بهتر) یه چیزی شبیه این میشه:
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 پاس داده میشه. اینجوری قبلاً مینوشتیم:
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>
</>
);
}و با کامپایلر، همین کد میشه:
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 رو تو پروژه فعال کنیم؟
اول پلاگین رو نصب کن:
npm install -D babel-plugin-react-compilerبعد بسته به ابزاری که استفاده میکنی، پلاگین رو به Babel اضافه کن. مثلاً تو Vite:
// 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 استفاده میکنی، نسخههای جدیدش یه گزینهی تنظیمات برای روشن کردن کامپایلر دارن. بهتره دقیقاً از داکیومنت نسخهای که نصب داری ببینی، چون اسم و محل این گزینه بین نسخهها فرق کرده:
// next.config.js
const nextConfig = {
reactCompiler: true,
};
module.exports = nextConfig;
اگه هنوز ریاکت ۱۹ نداری، کامپایلر با ۱۷ و ۱۸ هم کار میکنه، فقط باید پکیج react-compiler-runtime رو هم نصب کنی و نسخهی هدف رو تو تنظیمات پلاگین مشخص کنی. جزئیاتش تو داکیومنت رسمی هست.
کمکم شروع کن، نه یهویی
لازم نیست کل پروژه رو یکشبه بدی دست کامپایلر. میتونی فقط کامپوننتهایی رو که خودت علامت زدی کامپایل کنی:
// babel config
{
plugins: [
["babel-plugin-react-compiler", { compilationMode: "annotation" }],
],
}function HeavyChart({ data }) {
"use memo"; // فقط این کامپوننت کامپایل میشه
// ...
}و برعکس، اگه یه کامپوننت باهاش مشکل داشت، میتونی از کامپایل کنارش بذاری:
function LegacyWidget({ value }) {
"use no memo"; // کامپایلر این کامپوننت رو دست نمیزنه
// ...
}پس دیگه اصلاً useMemo و useCallback لازم نیست؟
تو بیشتر کدهای جدید، نه. ولی «بیشتر» با «همیشه» فرق داره. چند تا جا هست که هنوز بهشون نیاز داری یا حداقل بهتره نگهشون داری:
۱. وقتی تابع یا مقدار توی dependency یه useEffect هست
اگه یه افکت داری که با عوض شدن رفرنس یه تابع دوباره اجرا میشه و منطقت به این رفتار وابستهست، خیلی امنتره که ثبات اون رفرنس رو خودت صریحاً تضمین کنی. کامپایلر ممکنه memoization رو حفظ کنه، ولی کنترلش دست تو نیست و فقط وقتی «بهینهسازی» حساب میشه که کامپایلر روشنه:
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 رو نگه دار:
function MapView({ center, zoom }) {
const options = useMemo(
() => ({ center, zoom, controls: false }),
[center, zoom]
);
return <ThirdPartyMap options={options} />;
}۳. وقتی کامپایلر جا میزنه
کامپایلر فرض میکنه کدت از «قواعد ریاکت» پیروی میکنه: کامپوننتها pure باشن، stateها مستقیم mutate نشن، و هوکها شرطی صدا زده نشن. اگه یه تابعی این قواعد رو بشکنه، کامپایلر ترجیح میده اون تابع رو دست نزنه تا کدت خراب نشه. مثلاً:
function BadExample({ items }) {
// ❌ دارم prop رو مستقیم تغییر میدم (mutation)
items.push({ id: "new" });
return <List items={items} />;
}کدی مثل بالا یا اصلاً کامپایل نمیشه یا رفتار غیرمنتظره میگیره. راه درستش همون کد pure معمولیه:
function GoodExample({ items }) {
// ✅ یه کپی جدید میسازیم، بدون تغییر دادن prop
const nextItems = [...items, { id: "new" }];
return <List items={nextItems} />;
}خوبه که ESLint ریاکت رو هم فعال کنی؛ نسخههای جدید پلاگین هوکهای ریاکت، قواعدی دارن که همین جور مشکلها رو قبل از اجرا بهت نشون میدن.
۴. وقتی واقعاً محاسبهی خیلی سنگین داری
اگه یه محاسبهی خیلی خیلی سنگین داری (مثلاً پردازش یه آرایهی صدهزارتایی) و دقیقاً میخوای تضمین کنی فقط وقتی ورودی عوض شد اجرا بشه، میتونی useMemo رو صریح بنویسی تا نیتت تو کد معلوم باشه. فقط یادت باشه کامپایلر هم ممکنه همین کار رو انجام بده، پس حتماً قبل و بعد رو با Profiler اندازه بگیر.
نکتهی مهم برای پروژههای موجود
اگه پروژهات پر از useMemo و useCallbackه، لازم نیست همه رو پاک کنی. کامپایلر با اونا هم کار میکنه و حتی اگه یکیشون رو بیفایده تشخیص بده، میتونه کنارش بذاره. پیشنهاد عملی من اینه:
- اول کامپایلر رو فعال کن و ببین پروژه سالم بالا میاد یا نه.
- برای کدهای جدید، دیگه هوکهای memo رو پیشفرض ننویس.
- توی کدهای قدیمی، اونایی رو که مطمئنی فقط برای بهینهسازی بودن، کمکم و با تست بردار.
- اونایی که تو dependency افکتها هستن یا به کتابخونهی خارجی پاس داده میشن رو فعلاً دست نزن.
بهترین روشها و اشتباههای رایج
✅ چیزهایی که باید انجام بدی
- اول کد ساده بنویس و بذار کامپایلر بهینهش کنه.
useMemoوuseCallbackرو فقط وقتی واقعاً لازم شد استفاده کن.- قبل از بهینهسازی، با React DevTools Profiler اندازه بگیر.
- ESLint رو فعال کن تا تخطی از قواعد ریاکت رو سریع بگیره.
❌ اشتباههایی که زیاد میبینم
- همهچیز رو بیدلیل
useMemoوuseCallbackکردن و کد رو پیچیده کردن. - فرض کردن اینکه «همهچیز باید memo بشه» بدون اینکه اندازه گرفته باشی.
- فکر کردن اینکه کامپایلر بدون فعال کردن و تنظیم، خودبهخود کار میکنه.
- نوشتن کدی که props یا state رو مستقیم تغییر میده و بعد تعجب از اینکه کامپایلر کنارش گذاشته.
جمعبندی
React Compiler یکی از بهترین اتفاقهایی بود که برای تجربهی نوشتن کد ریاکت افتاد. کدها سادهتر، خواناتر و نگهداریشون راحتتر میشه، و بهجای اینکه حواست به آرایههای وابستگی باشه، میتونی روی خود منطق برنامه تمرکز کنی.
ولی حواست باشه که جادو نیست. کامپایلر به قواعد ریاکت تکیه میکنه، تو بعضی موقعیتها جا میزنه، و تو جاهایی مثل dependency افکتها و کتابخونههای خارجی هنوز ممکنه به useMemo و useCallback نیاز داشته باشی. قاعدهی طلایی همون چیزیه که اول مقاله گفتیم: اول ساده بنویس، بعد اندازه بگیر، و فقط جایی دستی دخالت کن که واقعاً لازمه.
تو پروژهی خودت کامپایلر رو امتحان کردی؟ اگه تجربهای داشتی یا جایی ازش گیر کردی، حتماً بگو. 😊
