اگه تا حالا یه Feature ساده اضافه کردی و سه تا باگ نامربوط گرفتی، میدونی چه حسی داره. ما توی یه پلتفرم چت دقیقاً همین وضع رو داشتیم، بهعلاوه گزارشهای Crash و یه عدد که اول باورم نشد: توی یه چت طولانی، فقط یه کامپوننت (Message) توی یه Session چند صد هزار بار Re-render میشد. چند صد هزار، نه چند هزار.
معلمی داشتم که میگفت «سؤال درست، نصف جوابه». توی Refactor هم همینطوره. قبل از هر کاری باید بپرسیم: واقعاً لازمه؟ اگه فقط کد رو قشنگتر میکنه و روی سرعت توسعه، باگها، Performance یا هزینه نگهداری اثری نداره، بهتره دست نزنیم.
توی پروژه ما چند تا از این مشکلات همزمان اتفاق افتاده بود: Crash زیاد، باگهای پشتسرهم با هر Feature ساده، و Re-renderهای چند صد هزارتایی. اینجا دیگه Refactor یه کار اضافه نبود، ضرورت بود.
قبل از هر کاری باید یه چیز رو روشن میکردیم: Re-render ذاتاً بد نیست. رندر معمولاً ارزونه و مشکل وقتی شروع میشه که سنگین باشه یا اونقدر تکرار بشه که Main Thread رو اشغال کنه. پس سؤال این نبود که «کجا Re-render داریم؟»، سؤال این بود که «کدوم Re-renderها واقعاً ضرر دارن؟» برای جواب دادن بهش دو تا ابزار کمکمون کرد.
اولی React Scan بود. Re-renderها رو روی صفحه Highlight میکنه و نشون میده کدوم کامپوننت، چند بار و چرا رندر شده. React DevTools Profiler هم همین اطلاعات رو داره و برای تحلیل عمیق عالیه، ولی برای پیدا کردن سریع مشکل توی کار روزمره، React Scan برای ما راحتتر بود.
ٖدومی INP بود. INP نشون میده بعد از هر تعامل کاربر (کلیک، تایپ، تپ) چقدر طول میکشه تا صفحه واقعاً جواب بده و تغییر روی صفحه دیده بشه. توی چت این دقیقاً همون چیزیه که کاربر حس میکنه، چون مدام Scroll میکنه، تایپ میکنه و پیام Streaming میگیره. Re-renderهای سنگین هم مستقیم همین زمان رو بالا میبرن، برای همین INP بهمون نشون میداد کدوم رندرها واقعاً به کاربر ضربه میزنن و کدومها بیضررن. ما INP رو با [web-vitals / RUM / پنل Performance] اندازه گرفتیم.
وقتی دنبال دلیل رندرهای پرضرر رفتیم، به یه نتیجه رسیدیم: مشکل فقط یهجا نبود و Re-render علامتِ چند تا ریشهی معماری بود. بریم سراغ همون ریشهها، یکییکی.
ٖProp Drilling وقتی اتفاق میافته که یه مقدار از چند لایه کامپوننت رد میشه، فقط برای اینکه در نهایت برسه به کامپوننتی که واقعاً بهش نیاز داره.
این کار کمکم باعث میشه ساختار کد پیچیدهتر بشه؛ مخصوصاً وقتی چند تا کامپوننت وسط مسیر عملاً کاری با اون Prop ندارن و فقط دارن پاسش میدن به لایهی بعدی.
اولین چیزی که ممکنه به ذهنمون برسه اینه که:
«خب، بریم بذاریمش توی Global Store!»
ولی همیشه لازم نیست.
قبل از اینکه سراغ Global Store بری، دو تا سؤال از خودت بپرس:
میشه ساختار کامپوننتها رو با Composition یا children بهتر کرد و اصلاً نیازی به رد کردن این Prop نباشه؟
میشه State رو به جایی که واقعاً ازش استفاده میشه نزدیکتر کرد؟
در مورد re-render هم یه نکته مهم وجود داره. اینطور نیست که صرفاً با Prop Drilling، حتماً تمام کامپوننتهای واسطه دوباره Render بشن. رفتار دقیقش به ساختار کامپوننتها و چیزهایی مثل memo بستگی داره. اما در هر صورت، وقتی داده از چند لایه رد میشه، وابستگی بین کامپوننتها بیشتر و مدیریت تغییرات سختتر میشه.
در نهایت، Global Store زمانی منطقیه که چند بخش نسبتاً مستقل از اپلیکیشن واقعاً به یه State مشترک نیاز داشته باشن.
پس قبل از اینکه برای هر Prop Drillingی سریع بری سراغ Global Store، یه بار به ساختار کامپوننتها و محل قرارگیری State نگاه کن. شاید اصلاً مشکل از State Management نباشه؛ شاید مشکل از ساختار کامپوننتها باشه.
ما برای Stateهایی که مدام عوض میشدن از Context استفاده کرده بودیم. مشکل اینجا بود که Context Selector نداره. با هر تغییر Value، همه کامپوننتهایی که با useContext مصرفش میکنن رندر میشن، حتی اگه فقط یه فیلد کوچیک ازش بخوان.
شاید بپرسی چرا React.memo یا useMemo نذاشتیم. چون کمکی نمیکردن. این دو فقط رندرهای بیدلیل رو میگیرن،
یعنی رندری که از Parent میاد یا وقتی Reference عوض میشه ولی مقدار واقعی تغییر نکرده. وقتی خودِ Value واقعاً عوض بشه، مصرفکنندههای Context رندر میشن، حتی اگه memo داشته باشن.
برای همین رفتیم سراغ Zustand. اونجا با یه Selector، هر کامپوننت فقط به همون تیکهای از State که لازم داره Subscribe میشه:
// Re-renders only when this message's status changes
const status = useChatStore((state) => state.messages[id]?.status)
دو تا نکته موقع استفاده از Selector مهمه. اول اینکه Selector باید مقدار Primitive یا Reference ثابت برگردونه. اگه هر بار Object یا Array جدید بسازه، کامپوننت هر بار رندر میشه. دوم، پیامها رو [Normalized و بر اساس id] نگه داشتیم تا با هر Token جدید فقط همون یه پیام آپدیت بشه.
چرا Zustand؟ چون Selector داخلیش دقیقاً همون چیزی بود که Context نداشت، بدون Provider و بدون کلی کد اضافه کار میکنه و آوردنش به پروژه سنگین نبود. سراغ Redux Toolkit نرفتیم چون برای نیاز ما ساختارش بیش از حد سنگین بود.
توی چت، Streaming یعنی State با هر Token عوض میشه، اون هم خیلی پرتکرار. مشکل اینجا بود که کامپوننتهای Streaming و بخشهای ثابت UI (Header، Sidebar، دکمهها) توی یه درخت State مشترک بودن. نتیجه این میشد که با هر Token جدید، بخشهایی که هیچ ربطی به پیام در حال نوشته شدن نداشتن هم رندر میشدن.
راهحل این بود که این دو دسته رو از هم جدا کنیم، با Composition و Selectorهای جدا. حالا موقع Streaming فقط خودِ کامپوننت Streaming و زیردرختش رندر میشه و بقیه UI از این ماجرا بیخبر میمونه..
خودِ پیام در حال Streaming هم رندر سنگینی داره، مخصوصاً وقتی Markdown داره. برای همین دو تا کار دیگه هم کردیم: Tokenها رو بهجای آپدیت تکبهتک، با requestAnimationFrame دستهای وارد State کردیم و بلاکهای Markdown که کامل شده بودن رو Memoize کردیم تا با رسیدن Token جدید دوباره پارس و رندر نشن.
توی کد ما چند جا از useEffect برای ساختن یه State از روی State دیگه استفاده شده بود. این الگو هم کد رو پیچیده میکنه و هم یه رندر اضافه میسازه: اول کامپوننت با مقدار قدیمی رندر میشه، بعد Effect اجرا میشه، بعد setState صدا زده میشه و دوباره رندر داریم. توی صفحهای که مدام آپدیت میگیره، این رندرهای اضافه جمع میشن.
برای همین یه قاعده گذاشتیم: قبل از نوشتن هر Effect از خودت بپرس «دارم کامپوننت رو با یه سیستم بیرونی (مثل WebSocket یا DOM API) هماهنگ میکنم، یا فقط یه مقدار رو از روی State دیگه حساب میکنم؟» اگه جواب
دومیه، Effect لازم نیست و مقدار رو همون موقع رندر حساب کن:
// Before: derived state via an effect (extra render)
const [visible, setVisible] = useState([])
useEffect(() => {
setVisible(messages.filter((m) => !m.hidden))
}, [messages])
// After: compute during render
const visible = useMemo(
() => messages.filter((m) => !m.hidden),
[messages]
)
این دقیقاً همون چیزیه که مستندات رسمی React توی صفحهی You Might Not Need an Effect توضیح داده.
رندر همه پیامهای یه Conversation توی حافظه و DOM گرون بود، مخصوصاً توی چتهای طولانی. برای حل این مشکل دو تا تکنیک داریم که کار یکی نیستن.
اولیش Pagination که حجم داده و State رو کم میکنه و هزینه اولین رندر رو پایین میاره. ولی تعداد Nodeهای DOM رو کنترل نمیکنه، چون اگه کاربر مدام بره بالا، پیامهای قدیمی یکییکی Append میشن و DOM دوباره بزرگ میشه. برای کنترل DOM باید سراغ Virtualization (مثل react-virtuoso یا TanStack Virtual) رفت، که فقط پیامهای دیدهشده توی صفحه رو رندر میکنه. ما برای همین از Virtualization هم استفاده کردیم تا DOM با طولانی شدن چت بزرگ نشه.
توی چت، Pagination فقط یه Query به Backend نیست. باید جوری باشه که با Load شدن پیامهای قدیمی، Scroll کاربر نپره و وسط خوندن گم نشه.
تعداد Re-render کامپوننت Message توی یه Session طولانی، از چند صد هزار به عددی رسید که دیگه توی React Scan به چشم نمیومد.
INP توی چتهای طولانی بهتر شد و تایپ کردن و Scroll حین Streaming دیگه حس لگ نداشت.
گزارشهای Crash صفحه چت عملاً از گزارشها حذف شد. دلیل اصلیش فشار رندر و حجم زیاد DOM توی چتهای طولانی بود و با ترکیب کم کردن Re-render و Virtualization حل شد.
Refactor رو قدمبهقدم جلو بردیم، نه یکجا، و بعد از هر قدم با همون ابزارها دوباره اندازه گرفتیم تا مطمئن بشیم مشکل واقعاً کم شده و چیزی خراب نشده.
ولی چیزی که برام از Zustand و React Scan و Virtualization مهمتره اینه:
Refactor باید از مسئله شروع بشه، نه از کد.
اول ببین چی واقعاً اذیتمون میکنه، بعد با ابزار درست اندازه بگیر، دلایل اصلی رو پیدا کن و کمهزینهترین تغییری رو بزن که همون مشکل رو حل میکنه. وگرنه ممکنه یه هفته وقت بذاری و صدها خط کد رو عوض کنی، بدون اینکه مشکلی از محصول حل بشه. گاهی هم عوض کردن ساختار چند تا کامپوننت و حذف چند تا Dependency اضافه، خیلی بیشتر اثر داره.
Refactor خوب یعنی کم کردن هزینه تغییر دادن سیستم، نه قشنگتر کردن کد.