বেশিরভাগ React পারফরম্যান্স টিউটোরিয়াল একই ভুল পরামর্শ দিয়ে শেষ হয়: সবকিছুকে useMemo এবং useCallback দিয়ে র‍্যাপ (wrap) করুন এবং কাজ শেষ বলে ধরে নিন। আপনি যদি সেই পরামর্শ অনুসরণ করে থাকেন, তবে সম্ভবত আপনি আপনার অ্যাপ্লিকেশনটিকে আরও ধীরগতির করে ফেলেছেন। এই হুকগুলো (hooks) বিনামূল্যে পাওয়া যায় না। প্রতিটি হুক মেমরি বরাদ্দ করে, ডিপেন্ডেন্সি (dependencies) তুলনা করে এবং ক্যাশ করা ভ্যালু (cached values) সংরক্ষণ করে। উদ্দেশ্য ছাড়া ব্যবহার করলে, এগুলো অপ্টিমাইজেশনের পরিবর্তে ওভারহেড (overhead) হয়ে দাঁড়ায়।

চলুন আসল বিষয়টি বোঝার জন্য অপ্রয়োজনীয় অংশগুলো বাদ দিই।

প্রতিটি হুক আসলে কী করে

useMemo একটি ভ্যালু (value) মনে রাখে। আপনি এটিকে একটি ফাংশন দেন যা ভারী কাজ করে, এবং এটি ফলাফলটি রিটার্ন করে। পরবর্তী রেন্ডারে, যদি আপনার ডিপেন্ডেন্সিগুলো পরিবর্তিত না হয়, তবে React ক্যালকুলেশনটি বাদ দেয় এবং আগের ফলাফলটি ফেরত দেয়।

useCallback একটি ফাংশন (function) মনে রাখে। এটি আপনার জন্য ফাংশনটি চালায় না। এটি কেবল রেন্ডারগুলোর মধ্যে একই ফাংশন ইনস্ট্যান্স (instance) রিটার্ন করে, যতক্ষণ না এর ডিপেন্ডেন্সিগুলো একই থাকে।

এটাই মূল পার্থক্য। একটি কম্পিউটেড ভ্যালু (computed value) ক্যাশ করে। অন্যটি একটি রেফারেন্স (reference) ক্যাশ করে। এই দুটির মধ্যে গুলিয়ে ফেললে কোড দেখতে অপ্টিমাইজড মনে হলেও এটি আনর‍্যাপড (unwrapped) কোডের মতোই আচরণ করে, অথচ অতিরিক্ত মেমরি খরচ করে।

কেন ফাংশন আইডেন্টিটি (Function Identity) আপনার ট্রি (Tree) নষ্ট করে দেয়

যখন একটি কম্পোনেন্ট রি-রেন্ডার (re-render) হয়, React পুরো ফাংশন বডিটি আবার এক্সিকিউট করে। প্রতিটি ভেরিয়েবল নতুন করে তৈরি হয়। প্রতিটি ইনলাইন ফাংশন মেমরিতে একটি সম্পূর্ণ নতুন অ্যাড্রেস পায়।

JavaScript-এ, দুটি ফাংশন যাতে হুবহু একই লজিক রয়েছে, তারা সমান নয়। () => {} === () => {} এর মান হলো false। একই নিয়ম অবজেক্ট এবং অ্যারের ক্ষেত্রেও প্রযোজ্য। যদি আপনার প্যারেন্ট কম্পোনেন্ট handleSubmit ডিফাইন করে এবং সেটি একটি চাইল্ড কম্পোনেন্টে পাস করে, তবে সেই চাইল্ড প্রতিটি রেন্ডারে একটি নতুন প্রপ (prop) পায়। এমনকি চাইল্ডটি যদি React.memo দিয়ে র‍্যাপ করা থাকে, তবুও এটি বুঝতে পারে না যে নতুন ফাংশনটি পুরনো ফাংশনের মতোই কাজ করছে। রেফারেন্স পরিবর্তিত হয়েছে, তাই চাইল্ডটি রি-রেন্ডার হয়।

এটিই সেই মূল সমস্যা যা সমাধান করার জন্য useCallback তৈরি করা হয়েছে। এটি গতির (speed) বিষয় নয়। এটি স্থিতিশীলতার (stability) বিষয়।

কখন useMemo ব্যবহার করা সার্থক হয়

আপনার useMemo প্রয়োজন তখন, যখন আপনি এমন কোনো কাজ করছেন যা বস্তুগতভাবে ব্যয়বহুল (expensive) এবং আপনি একটি পরিমাপযোগ্য ল্যাগ (lag) দেখতে পাচ্ছেন।

একটি বিশাল ডেটাসেট ফিল্টার করার কথা চিন্তা করুন। যদি আপনার কাছে হাজার হাজার রো (row) বিশিষ্ট একটি টেবিল এবং একটি সার্চ ইনপুট থাকে, তবে আপনি আপনার কম্পোনেন্টের ভেতরে অনেকটা এরকম কিছু লিখতে পারেন:

const visibleRows = rows.filter(r => r.name.includes(query));

useMemo ছাড়া, সেই লুপটি প্রতিটি রেন্ডারে চলে। যদি ব্যবহারকারী এমন একটি বাটনে ক্লিক করেন যা সাইডবার টগল করে, তবে প্যারেন্ট রি-রেন্ডার হয় এবং rowsquery পরিবর্তিত না হওয়া সত্ত্বেও আপনার ফিল্টারটি আবার চলে। একটি বড় ডেটাসেটের ক্ষেত্রে, সেই ধীরগতি স্পষ্টভাবে বোঝা যায়।

useMemo ফলাফলটিকে স্থির (pinning) করে এই সমস্যাটি সমাধান করে:

const visibleRows = useMemo(() => {
  return rows.filter(r => r.name.includes(query));
}, [rows, query]);

এখন React কেবল তখনই সেই ফিল্টারটি পুনরায় চালায় যখন ডিপেন্ডেন্সিগুলো প্রকৃতপক্ষে পরিবর্তিত হয়।

একই লজিক জটিল গাণিতিক গণনা, API রেসপন্সকে চার্ট-ফ্রেন্ডলি ফরম্যাটে রূপান্তর করা, বা এমন স্টেট (state) তৈরি করার ক্ষেত্রেও প্রযোজ্য যা অন্যথায় প্রতিনিয়ত পুনরায় গণনা করতে হতো।

এখানে দ্বিতীয় একটি কম পরিচিত ব্যবহার রয়েছে। আপনি যদি লোকালি একটি অবজেক্ট বা অ্যারে তৈরি করেন এবং সেটি একটি useEffect ডিপেন্ডেন্সি অ্যারেতে অন্তর্ভুক্ত করেন, তবে আপনি ভুলবশত প্রতিটি রেন্ডারে সেই ইফেক্টটি ট্রিগার করতে পারেন। ইনলাইন অবজেক্ট এবং অ্যারেগুলো প্রতিবার নতুন আইডেন্টিটি পায়, তাই ইফেক্টটি পরিবর্তিত ডিপেন্ডেন্সি দেখে আবার চলে। useMemo দিয়ে সেই অবজেক্টটিকে মেমোইজ (memoize) করলে রেফারেন্সটি স্থিতিশীল থাকে এবং আপনার ইফেক্টটি কেবল তখনই চলে যখন মূল ডেটা প্রকৃতপক্ষে পরিবর্তিত হয়।

কখন useCallback প্রয়োজনীয় হয়ে ওঠে

useCallback সবচেয়ে বেশি গুরুত্বপূর্ণ তখন, যখন আপনি এমন চাইল্ড কম্পোনেন্টে হ্যান্ডলার (handlers) পাস করছেন যা React.memo দিয়ে অপ্টিমাইজ করা হয়েছে।

একটি প্যারেন্ট কম্পোনেন্টের কথা কল্পনা করুন যা একটি কাউন্টার ধারণ করে। এটি একটি ব্যয়বহুল (expensive) চাইল্ড লিস্টও রেন্ডার করে:

function Parent() {
  const [count, setCount] = useState(0);
  
  const handleItemClick = (id) => {
    console.log(id);
  };
  
  return (
    <div>
      <button onClick={() => setCount(c + 1)}>{count}</button>
      <ExpensiveList onItemClick={handleItemClick} />
    </div>
  );
}

প্রতিবার যখন count পরিবর্তিত হয়, Parent রি-রেন্ডার হয়। একটি নতুন handleItemClick তৈরি হয়। যেহেতু ExpensiveList একটি নতুন প্রপ রেফারেন্স পায়, তাই এটিও রি-রেন্ডার হয়। যদি ExpensiveList কে React.memo দিয়ে র‍্যাপ করা থাকে, তবে সেই মেমোইজেশন সম্পূর্ণ বৃথা যায় কারণ ফাংশন প্রপটি পরিবর্তিত হয়েছে।

useCallback রেফারেন্সটি সংরক্ষণ করে:

const handleItemClick = useCallback((id) => {
  console.log(id);
}, []);

এখন ExpensiveList কেবল তখনই রি-রেন্ডার হয় যখন এটি সত্যিই প্রয়োজন।

আরেকটি গুরুত্বপূর্ণ পরিস্থিতি হলো useEffect সংক্রান্ত। যদি কোনো ইফেক্ট আপনার কম্পোনেন্টের ভেতরে ডিফাইন করা একটি ফাংশনের ওপর সাবস্ক্রাইব করে এবং সেই ফাংশনটি প্রতিটি রেন্ডারে তার আইডেন্টিটি পরিবর্তন করে, তবে ইফেক্টটি বারবার টিয়ারডাউন (teardown) এবং পুনরায় সাবস্ক্রাইব করবে। ফাংশনটিকে মেমোইজ করলে ইফেক্টটি স্থিতিশীল থাকে।

ডিপেন্ডেন্সি অ্যারে ট্র্যাপ (Dependency Array Trap) এবং স্টেল ক্লোজার (Stale Closures)

উভয় হুকই ডিপেন্ডেন্সি অ্যারের ওপর নির্ভর করে, আর এখানেই বেশিরভাগ বাগ (bug) লুকিয়ে থাকে।

আপনি যদি dependency array থেকে কোনো variable বাদ দেন, তবে আপনার memoized function বা value সেই variable-এর একটি পুরনো ভার্সন নিয়ে কাজ করবে। একে বলা হয় stale closure। UI হয়তো নতুন ডেটা দেখাবে, কিন্তু আপনার callback তখনও তিন রেন্ডার আগের state-এর দিকে তাকিয়ে থাকবে। এর সমাধান সহজ কিন্তু কোড রিভিউ করার সময় এটি এড়িয়ে যাওয়ার সম্ভাবনা থাকে: হুকের ভেতরে ব্যবহৃত প্রতিটি value যা পরিবর্তন হতে পারে, তা অন্তর্ভুক্ত করুন।

react-hooks/exhaustive-deps ESLint rule-টি চালান। এটি স্পষ্ট ভুলগুলো ধরে ফেলবে। তবে এটিকে রোবটের মতো অন্ধভাবে অনুসরণ করবেন না। প্রতিটি dependency কেন গুরুত্বপূর্ণ তা বোঝার চেষ্টা করুন।

অতিরিক্ত অপ্টিমাইজেশনের অদৃশ্য মূল্য

নতুনরা প্রায়ই প্রতিটি function এবং value-কে এই hook দিয়ে সুরক্ষিত করার চেষ্টা করেন কারণ এতে নিরাপদ মনে হয়। কিন্তু এই অভ্যাসটি উল্টো ফল দিতে পারে।

React-কে মেমোরিতে cached value গুলো জমা রাখতে হয়। প্রতিটি render-এর সময়, এটি আপনার dependency array-এর মধ্য দিয়ে iterate করে এবং Object.is ব্যবহার করে প্রতিটি item তুলনা করে। এই তুলনা করা সহজ হলেও এটি সম্পূর্ণ বিনামূল্যে হয় না। আপনি যদি onClick={() => setOpen(true)}-এর মতো একটি সাধারণ event handler-কে useCallback-এর ভেতরে মুড়িয়ে (wrap) ফেলেন, তবে আপনি মেমোরি এবং CPU খরচ করছেন এমন একটি function তৈরি এড়াতে যা তৈরি করা অত্যন্ত দ্রুত হতো।

এই hook গুলো কোডে অপ্রয়োজনীয় জটিলতা (noise) যোগ করে। useMemo এবং useCallback দিয়ে মোড়ানো কোড পড়া এবং রক্ষণাবেক্ষণ করা কঠিন। প্রতিটি dependency array হলো একটি সম্ভাব্য stale closure যা যেকোনো সময় সমস্যা তৈরি করতে পারে।

আসল নিয়মটি খুব একটা আকর্ষণীয় না হলেও কার্যকর: প্রথমে সাধারণ কোড লিখুন। শুধুমাত্র যখন কোনো সমস্যার প্রমাণ পাবেন, তখনই অপ্টিমাইজ করুন। কোন component গুলো বেশি সময় নিচ্ছে এবং কোন render গুলো অপচয় করছে তা শনাক্ত করতে React DevTools Profiler ব্যবহার করুন। যদি একটি render সম্পন্ন হতে কয়েক মিলিসেকেন্ডের কম সময় লাগে, তবে কোনো ব্যবহারকারী তা বুঝতে পারবেন না, এবং আপনার memoization আসলে কিছুই সমাধান করছে না।

মূল কথা

useMemo ব্যবহার করা হয় ব্যয়বহুল (expensive) value-র জন্য। useCallback ব্যবহার করা হয় স্থিতিশীল (stable) function reference-এর জন্য। কোনো hook-ই নিজে থেকে আপনার component-এর render দ্রুত করে না; তারা কেবল অপ্রয়োজনীয় পরবর্তী কাজ (downstream work) প্রতিরোধ করে। এগুলো ছাড়াই কাজ শুরু করুন, সঠিক টুলিং দিয়ে পরিমাপ করুন এবং ঠিক সেখানেই এগুলো যোগ করুন যেখানে profiler কোনো bottleneck শনাক্ত করে। মাঝে মাঝে rerender হওয়া পরিষ্কার কোড প্রায় সব সময় এমন over-engineered কোডের চেয়ে ভালো হবে যা সবকিছু memoize করার চেষ্টা করে।