Most React performance tutorials end with the same bad advice: wrap everything in useMemo and useCallback and call it a day. If you have followed that advice, you have probably made your application slower. These hooks are not free. Each one allocates memory, compares dependencies, and stores cached values. Used without purpose, they become overhead instead of optimization.

เรามาตัดส่วนที่ไม่จำเป็นออก แล้วดูสิ่งที่สำคัญจริงๆ กันดีกว่า

สิ่งที่ Hook แต่ละตัวทำจริงๆ

useMemo จะจดจำ ค่า (value) คุณส่งฟังก์ชันที่ทำงานหนักๆ ให้มัน แล้วมันจะคืนค่าผลลัพธ์กลับมา ในการเรนเดอร์ครั้งถัดไป หาก dependencies ของคุณไม่มีการเปลี่ยนแปลง React จะข้ามขั้นตอนการคำนวณและส่งผลลัพธ์เดิมกลับมาให้แทน

useCallback จะจดจำ ฟังก์ชัน (function) มันไม่ได้รันฟังก์ชันให้คุณ แต่มันจะคืนค่า function instance เดิมระหว่างการเรนเดอร์ ตราบใดที่ dependencies ยังคงเหมือนเดิม

นี่คือความแตกต่างทั้งหมด อย่างหนึ่งแคชค่าที่คำนวณแล้ว (computed value) ส่วนอีกอย่างแคชการอ้างอิง (reference) การสับสนระหว่างสองสิ่งนี้จะทำให้โค้ดดูเหมือนได้รับการปรับแต่ง (optimized) แต่กลับทำงานเหมือนกับโค้ดที่ไม่ได้ครอบ hook เลย แถมยังต้องเสียหน่วยความจำเพิ่มขึ้นอีกด้วย

ทำไม Function Identity ถึงทำให้ Tree ของคุณพัง

เมื่อคอมโพเนนต์เกิดการ re-render, React จะรันโค้ดใน function body ทั้งหมดใหม่อีกครั้ง ตัวแปรทุกตัวจะถูกสร้างขึ้นใหม่ และ inline function ทุกตัวจะได้ที่อยู่ (address) ใหม่ในหน่วยความจำ

ใน JavaScript ฟังก์ชันสองฟังก์ชันที่มีตรรกะเหมือนกันทุกประการจะไม่ถือว่าเท่ากัน () => {} === () => {} จะมีค่าเป็น false กฎเดียวกันนี้ยังใช้กับ object และ array ด้วย หากคอมโพเนนต์ตัวแม่ (parent) กำหนด handleSubmit และส่งต่อไปยังคอมโพเนนต์ตัวลูก (child) ตัวลูกจะได้รับ prop ใหม่ในทุกๆ การเรนเดอร์ แม้ว่าตัวลูกจะถูกครอบด้วย React.memo แต่มันก็ไม่สามารถบอกได้ว่าฟังก์ชันใหม่นั้นทำงานเหมือนกับฟังก์ชันเดิม เพราะ reference เปลี่ยนไป ตัวลูกจึงต้อง re-render ใหม่

นี่คือปัญหาหลักที่ useCallback ถูกสร้างขึ้นมาเพื่อแก้ไข มันไม่ใช่เรื่องของความเร็ว แต่มันคือเรื่องของความเสถียร (stability)

เมื่อไหร่ที่ useMemo จะคุ้มค่าที่จะใช้

คุณจำเป็นต้องใช้ useMemo เมื่อคุณกำลังทำงานที่มีต้นทุนสูง (expensive) อย่างเห็นได้ชัด และคุณสามารถสังเกตเห็นความหน่วง (lag) ได้

ลองนึกถึงการกรองชุดข้อมูลขนาดใหญ่ (massive dataset) หากคุณมีตารางที่มีข้อมูลหลายหมื่นแถวและมีช่องค้นหา คุณอาจจะเขียนโค้ดในคอมโพเนนต์ประมาณนี้:

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

หากไม่มี useMemo ลูปนั้นจะทำงานทุกครั้งที่มีการเรนเดอร์ หากผู้ใช้คลิกปุ่มเพื่อเปิด/ปิด sidebar ตัว parent จะ re-render และฟังก์ชัน filter ของคุณก็จะทำงานอีกครั้ง ทั้งที่ rows และ query ไม่ได้เปลี่ยนแปลงเลย สำหรับชุดข้อมูลขนาดใหญ่ ความหน่วง (stutter) นี้จะสังเกตเห็นได้ชัดเจน

useMemo จะเข้ามาแก้ปัญหานี้โดยการตรึง (pinning) ผลลัพธ์ไว้:

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

ตอนนี้ React จะรันฟังก์ชัน filter นั้นใหม่ก็ต่อเมื่อ dependencies มีการเปลี่ยนแปลงจริงๆ เท่านั้น

ตรรกะเดียวกันนี้ยังใช้กับการคำนวณทางคณิตศาสตร์ที่ซับซ้อน, การแปลงข้อมูลจาก API ให้อยู่ในรูปแบบที่เหมาะสำหรับกราฟ, หรือการสร้าง state (deriving state) ที่หากไม่ทำเช่นนี้ก็จะถูกคำนวณใหม่ตลอดเวลา

ยังมีอีกกรณีหนึ่งที่สังเกตเห็นได้ยากกว่า หากคุณสร้าง object หรือ array ขึ้นมาภายในคอมโพเนนต์ (locally) และใส่ไว้ใน dependency array ของ useEffect คุณอาจจะเผลอทำให้ effect นั้นทำงานทุกครั้งที่มีการเรนเดอร์ เนื่องจาก inline object และ array จะได้ identity ใหม่ทุกครั้ง ทำให้ effect มองว่า dependency เปลี่ยนแปลงและทำงานซ้ำอีกครั้ง การทำ memoization ให้กับ object นั้นด้วย useMemo จะช่วยรักษา reference ให้คงที่ และทำให้ effect ของคุณทำงานเฉพาะเมื่อข้อมูลพื้นฐานมีการเปลี่ยนแปลงจริงๆ เท่านั้น

เมื่อไหร่ที่ useCallback กลายเป็นสิ่งจำเป็น

useCallback จะมีความสำคัญมากที่สุดเมื่อคุณต้องส่ง handler ไปยังคอมโพเนนต์ลูกที่ได้รับการปรับแต่งด้วย React.memo

ลองนึกถึงคอมโพเนนต์ parent ที่เก็บค่า counter และยังเรนเดอร์รายการลูก (child list) ที่ทำงานหนัก (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 จะ re-render และ handleItemClick ตัวใหม่จะถูกสร้างขึ้น เนื่องจาก ExpensiveList ได้รับ reference ของ prop ใหม่ มันจึงต้อง re-render ตามไปด้วย หาก ExpensiveList ถูกครอบด้วย React.memo การทำ memoization นั้นก็จะสูญเปล่าโดยสิ้นเชิง เพราะ prop ที่เป็นฟังก์ชันมีการเปลี่ยนแปลง

useCallback จะช่วยรักษา reference ไว้:

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

ตอนนี้ ExpensiveList จะ re-render เฉพาะเมื่อจำเป็นจริงๆ เท่านั้น

อีกสถานการณ์ที่สำคัญคือเรื่อง useEffect หาก effect มีการ subscribe ฟังก์ชันที่ถูกนิยามไว้ภายในคอมโพเนนต์ และฟังก์ชันนั้นเปลี่ยน identity ในทุกการเรนเดอร์ effect จะทำการ teardown และ resubscribe ซ้ำไปซ้ำมา การทำ memoization ให้กับฟังก์ชันจะช่วยให้ effect มีความเสถียร

กับดักของ Dependency Array และ Stale Closures

Hook ทั้งสองตัวต้องพึ่งพา dependency array และนี่คือจุดที่บั๊กส่วนใหญ่มักจะซ่อนอยู่

หากคุณละเว้นตัวแปรจาก dependency array ฟังก์ชันหรือค่าที่ถูก memoized ไว้จะไปอ้างอิง (close over) ตัวแปรเวอร์ชันเก่า สิ่งนี้เรียกว่า stale closure แม้ว่า UI จะแสดงข้อมูลล่าสุด แต่ callback ของคุณยังคงมองไปที่ state จากการ render เมื่อสามครั้งก่อน วิธีแก้ไขนั้นง่ายแต่ก็อาจถูกมองข้ามได้ง่ายระหว่างการทำ code review นั่นคือการใส่ทุกค่าที่ถูกใช้งานภายใน hook ซึ่งมีโอกาสเปลี่ยนแปลงได้

ให้รันกฎ ESLint react-hooks/exhaustive-deps ซึ่งจะช่วยตรวจจับการละเว้นที่เห็นได้ชัด แต่จงอย่าปฏิบัติกับมันเหมือนเป็นหุ่นยนต์ จงทำความเข้าใจว่า ทำไม แต่ละ dependency ถึงมีความสำคัญ

ภาษีแฝงจากการทำ Over-Optimization

มือใหม่มักจะใช้ hook เหล่านี้ครอบทุกฟังก์ชันและทุกค่าเพราะรู้สึกว่าปลอดภัย แต่นิสัยนี้จะส่งผลเสียในภายหลัง

React ต้องจัดเก็บค่าที่ถูก cache ไว้ในหน่วยความจำ ในทุกๆ การ render มันจะต้องไล่ดู (iterate) ผ่าน dependency array ของคุณและเปรียบเทียบแต่ละรายการด้วย Object.is การเปรียบเทียบนั้นใช้ทรัพยากรน้อย แต่ก็ไม่ใช่ว่าไม่มีต้นทุนเลย หากคุณนำ event handler ง่ายๆ อย่าง onClick={() => setOpen(true)} ไปครอบด้วย useCallback คุณกำลังจ่ายต้นทุนด้านหน่วยความจำและ CPU เพื่อหลีกเลี่ยงการสร้างฟังก์ชันซึ่งจริงๆ แล้วใช้เวลาเพียงเสี้ยววินาทีในการจองพื้นที่ (allocate)

Hook เหล่านี้ยังเพิ่มความซับซ้อนที่ไม่จำเป็น (noise) อีกด้วย โค้ดที่ถูกครอบด้วย useMemo และ useCallback จะอ่านยากขึ้นและดูแลรักษาได้ยากขึ้น ทุกๆ dependency array คือโอกาสที่จะเกิด stale closure ที่รอจังหวะจะเล่นงานคุณ

กฎเหล็กที่แท้จริงอาจดูไม่หวือหวาแต่ได้ผลดี คือ: เขียนโค้ดแบบธรรมดา (plain code) ไปก่อน แล้วค่อยทำ optimization เมื่อคุณมีหลักฐานว่าเกิดปัญหาขึ้นเท่านั้น จงใช้ React DevTools Profiler เพื่อระบุว่า component ไหนที่ใช้ทรัพยากรสูง และการ render ครั้งไหนที่สิ้นเปลือง หากการ render ใช้เวลาน้อยกว่าไม่กี่มิลลิวินาที ผู้ใช้จะไม่มีทางสังเกตเห็น และการทำ memoization ของคุณก็ไม่ได้ช่วยแก้ปัญหาอะไรเลย

บทสรุป

useMemo มีไว้สำหรับค่าที่มีต้นทุนสูง (expensive values) ส่วน useCallback มีไว้สำหรับอ้างอิงฟังก์ชันที่คงที่ (stable function references) ไม่มี hook ตัวไหนที่ทำให้ component ของคุณ render เร็วขึ้นได้ด้วยตัวมันเอง แต่มันช่วยป้องกันงานที่ไม่จำเป็นในขั้นตอนถัดไป (downstream work) จงเริ่มโดยไม่ใช้พวกมันก่อน วัดผลด้วยเครื่องมือจริง แล้วค่อยเพิ่มพวกมันเข้าไปในจุดที่ profiler แสดงให้เห็นว่าเป็นคอขวด (bottleneck) อย่างแม่นยำ โค้ดที่สะอาดซึ่งมีการ rerender บ้างเป็นครั้งคราว มักจะดีกว่าโค้ดที่ถูกออกแบบมาเกินความจำเป็น (over-engineered) ซึ่งพยายามทำ memoize ทุกอย่าง