คุณเคยสร้าง tooltip ที่กระโดดจากมุมบนซ้ายไปยังตำแหน่งที่ถูกต้องไหม? หรือ modal ที่กะพริบด้วยขนาดที่ผิดก่อนจะเข้าที่? อาการกระตุกเพียงเสี้ยววินาทีนั้นคือ layout flicker มันเกิดขึ้นเมื่อ React อ่าน DOM, คำนวณการแก้ไข และอัปเดต state แต่เบราว์เซอร์ได้เริ่มวาดพิกเซลลงบนหน้าจอไปแล้ว วิธีแก้ปัญหาทั่วไปคือการเปลี่ยนจาก useEffect เป็น useLayoutEffect การเปลี่ยนนี้ได้ผล แต่คุณต้องเข้าใจก่อนว่า hook แต่ละตัวทำงานตอนไหนภายใน browser pipeline
The Browser Pipeline: Render, Commit, Paint
React อัปเดตคอมโพเนนต์ใน 3 ขั้นตอนที่แตกต่างกัน ในขั้นตอน render React จะสร้าง—หรือสร้างใหม่—Virtual DOM และคำนวณ diff ยังไม่มีการเปลี่ยนแปลงพิกเซลจริง ๆ นี่คือการคำนวณล้วน ๆ ที่เกิดขึ้นในหน่วยความจำ ต่อมาคือขั้นตอน commit ซึ่ง React จะนำการเปลี่ยนแปลงเหล่านั้นไปใช้กับ DOM nodes จริง ๆ สไตล์จะถูกอัปเดต, node จะถูกแทรกหรือลบออก และข้อความจะเปลี่ยนไป
จากนั้นเบราว์เซอร์จะเข้ามาจัดการ ในขั้นตอน paint เรนเดอริ่งเอนจินของเบราว์เซอร์จะคำนวณ layout geometry และวาดพิกเซลลงบนหน้าจอ ลำดับนี้มีความตายตัว เบราว์เซอร์ต้องทำ layout ให้เสร็จก่อนจึงจะ paint ได้ และต้อง paint ให้เสร็จก่อนที่ผู้ใช้จะเห็นสิ่งใหม่ ช่องว่างระหว่าง commit และ paint วัดเป็นมิลลิวินาที แต่มันมีอยู่จริง และเป็นช่วงเวลาที่ useEffect และ useLayoutEffect ทำงานต่างกัน
ทำไม useEffect ถึงทำให้เกิดอาการ flicker
useEffect ทำงานแบบ asynchronous โดยถูกกำหนดให้ทำงานหลังจากที่เบราว์เซอร์วาดหน้าจอ (paint) ไปแล้ว DOM ถูกอัปเดต, พิกเซลถูกวาด และจากนั้น React จึงกลับเข้ามาทำงาน effect ของคุณ
ลองจินตนาการว่าคุณเรนเดอร์ dropdown menu ใต้ปุ่มหนึ่ง ใน useEffect คุณเรียก buttonRef.current.getBoundingClientRect() เพื่อคำนวณพิกัด top และ left ที่ถูกต้อง แล้วเก็บไว้ใน state เนื่องจาก useEffect ทำงานหลังการ paint เบราว์เซอร์จึงวาด dropdown ในตำแหน่งเริ่มต้นไปแล้ว เช่น top: 0, left: 0 หลังจาก paint นั้นเองที่ effect ของคุณจะอัปเดต state จากนั้น React จะ commit พิกัดที่แก้ไขแล้ว และเบราว์เซอร์จะ paint อีกครั้ง ผู้ใช้จะเห็นสองเฟรม: ตำแหน่งที่ผิด แล้วตามด้วยตำแหน่งที่ถูก การกระโดดทางสายตานั้นคืออาการ flicker ที่ทุกคนพยายามหลีกเลี่ยง
สำหรับการดึงข้อมูล (data fetching), การเรียก API, การติดตาม analytics หรือการตั้งค่า event listeners ความล่าช้านี้ไม่มีผล ผู้ใช้ไม่สนใจว่า analytics beacon จะทำงานหลังจาก paint ไปไม่กี่มิลลิวินาที ในความเป็นจริง การผลักงานที่ไม่เกี่ยวกับภาพไปไว้หลังการ paint จะช่วยให้การเรนเดอร์เริ่มต้นตอบสนองได้ดีขึ้น แต่สำหรับการแก้ไขที่ขึ้นอยู่กับเลย์เอาต์ useEffect นั้นช้าเกินไป
useLayoutEffect บล็อกการ paint อย่างไร
useLayoutEffect ทำงานแบบ synchronous ทันทีหลังจาก React เปลี่ยนแปลง DOM แต่ก่อนที่เบราว์เซอร์จะมีโอกาสคำนวณ layout หรือวาดพิกเซล มันจะบล็อก pipeline การ paint ทั้งหมด
หากคุณทำการวัดขนาด dropdown แบบเดิมภายใน useLayoutEffect ลำดับจะเปลี่ยนไป React จะ commit การอัปเดต DOM เริ่มต้น, รัน layout effect ของคุณ และการอัปเดต state ของคุณจะกระตุ้นการ re-render แบบ synchronous React จะ commit พิกัดที่แก้ไขแล้ว และหลังจากนั้นเท่านั้นที่เบราว์เซอร์จะ paint ผู้ใช้จะเห็นเพียงเฟรมเดียวซึ่งถูกต้องแล้ว
พฤติกรรมการบล็อกนี้เป็นทั้งฟีเจอร์และความเสี่ยง เนื่องจาก useLayoutEffect ป้องกันไม่ให้เบราว์เซอร์ paint จนกว่ามันจะทำงานเสร็จ การคำนวณที่หนักเกินไปภายใน hook นี้จะทำให้ UI ค้าง แม้จะบล็อกการ paint เพียงไม่กี่สิบมิลลิวินาที ผู้ใช้ก็รู้สึกถึงอาการหน่วง (jank) ได้ นี่คือเหตุผลที่เอกสารของ React แนะนำให้เริ่มด้วย useEffect และเปลี่ยนไปใช้ useLayoutEffect เฉพาะเมื่อคุณสังเกตเห็นอาการ flicker ที่ยอมรับไม่ได้เท่านั้น
ควรใช้ hook แต่ละตัวเมื่อไหร่
ตรรกะส่วนใหญ่ของคุณควรอยู่ใน useEffect ใช้สำหรับ:
- การดึงข้อมูลจาก API
- การตั้งค่า subscriptions หรือ event listeners
- การส่ง analytics events
- side effect ใด ๆ ที่ไม่ได้อ่านหรือเปลี่ยนแปลงเลย์เอาต์ในทันที
สงวน useLayoutEffect ไว้สำหรับการทำงานที่ต้องอ่าน DOM และเขียนค่ากลับก่อนที่ผู้ใช้จะเห็นเฟรม:
- การวัดขนาดของ element เช่น ความกว้าง, ความสูง หรือตำแหน่งการ scroll
- การคำนวณพิกัดสำหรับ tooltips, popovers หรือ context menus
- การป้องกันการขยับของเลย์เอาต์ (layout shifts) ที่มองเห็นได้ เมื่อตำแหน่งทางสายตาขึ้นอยู่กับ geometry ที่เรนเดอร์ออกมา
หากคุณไม่แน่ใจว่าจะเลือกตัวไหน ให้ใช้ useEffect เป็นค่าเริ่มต้น เปลี่ยนไปใช้ useLayoutEffect เฉพาะเมื่อคุณสังเกตเห็นความไม่เสถียรทางสายตาเท่านั้น กฎนี้เพียงข้อเดียวจะช่วยให้แอปพลิเคชัน React ส่วนใหญ่ทำงานได้อย่างราบรื่น
ข้อควรระวังเรื่อง Server-Side Rendering
หากคุณใช้ Next.js, Remix หรือเฟรมเวิร์กใดก็ตามที่เรนเดอร์ React บนเซิร์ฟเวอร์ คุณจะพบคำเตือนเกี่ยวกับ useLayoutEffect เนื่องจากเซิร์ฟเวอร์ไม่มี DOM ตัว hook จึงไม่มีอะไรให้วัดค่าได้ React จะเตือนคุณว่ามันคาดหวังสภาพแวดล้อมแบบเบราว์เซอร์แต่ไม่พบ ในระหว่างขั้นตอน hydration ความไม่สอดคล้องกันนี้อาจทำให้เกิดบั๊กที่ตรวจพบได้ยาก เนื่องจาก markup ที่เรนเดอร์จากเซิร์ฟเวอร์และผลลัพธ์การเรนเดอร์ครั้งแรกที่ตั้งใจไว้ของฝั่ง client อาจแตกต่างกัน
วิธีแก้ไขมาตรฐานคือการใช้ isomorphic hook ที่เลือก effect ที่เหมาะสมตามสภาพแวดล้อม:
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
ใช้ wrapper นี้ในคอมโพเนนต์ใดก็ตามที่จำเป็นต้องวัดขนาด DOM nodes แต่มีโอกาสที่จะทำงานระหว่างการเรนเดอร์บนเซิร์ฟเวอร์ มันจะช่วยปิดคำเตือนและทำให้ผลลัพธ์จากเซิร์ฟเวอร์ของคุณมีความสอดคล้องกัน
ประสิทธิภาพและแนวทางปฏิบัติที่ดีที่สุด
เนื่องจาก useLayoutEffect จะบล็อกการวาดภาพ (painting) ควรเขียนโค้ดภายใน hook ให้เบาที่สุดเท่าที่จะเป็นไปได้ อ่านค่า layout, คำนวณค่าที่ต้องแก้ไข และเขียนค่ากลับลงไป อย่าดึงข้อมูล (fetch data), parse ออบเจกต์ขนาดใหญ่ หรือรันอัลกอริทึมที่กินทรัพยากรสูงภายใน hook นี้ โค้ดที่หนักเกินไปในส่วนนี้จะทำให้ main thread หยุดชะงัก และทำให้ interface ของคุณรู้สึกเหมือนค้าง
เมื่อคุณต้องการวัดขนาดองค์ประกอบต่างๆ ให้ใช้ React refs แทนการใช้ document.getElementById เนื่องจาก refs จะผูกติดกับ instance ของคอมโพเนนต์ของคุณ สามารถคงอยู่ได้แม้จะมีการ re-render โดยไม่ต้องใช้เทคนิคการ query และทำงานได้อย่างน่าเชื่อถือร่วมกับ portals หรือการเรนเดอร์แบบมีเงื่อนไข (conditional rendering) การค้นหาด้วย ID แบบ global จะทำลายการห่อหุ้ม (encapsulation) ของคอมโพเนนต์ และอาจคืนค่าเป็น null ในจังหวะที่คุณจำเป็นต้องใช้งานมันพอดี
useEffect คือค่าเริ่มต้นที่เหมาะสมสำหรับ side effect เกือบทุกประเภท มันช่วยให้เบราว์เซอร์สามารถวาดภาพ (paint) ได้โดยไม่ถูกขัดจังหวะ และจัดการกับข้อมูล, events และการซิงโครไนซ์ภายนอกได้อย่างราบรื่น ส่วน useLayoutEffect เป็นเครื่องมือเฉพาะทางสำหรับปัญหาเฉพาะอย่าง นั่นคือการอ่านค่า layout และเขียนค่ากลับก่อนที่จะมีการวาดภาพ (paint) หากคุณเข้าใจความแตกต่างเรื่องจังหวะเวลา (timing) ระหว่างทั้งสองอย่าง คุณจะเลิกไล่ตามอาการภาพกระพริบ (flickers) และเริ่มป้องกันมันได้แทน
บทสรุปที่สำคัญ: เริ่มต้นด้วย useEffect สำหรับทุกอย่าง ทันทีที่คุณเห็น tooltip หรือ modal กระพริบไปยังตำแหน่งที่ผิดก่อนจะขยับเข้าที่ นั่นคือสัญญาณของคุณ ให้เปลี่ยนไปใช้ useLayoutEffect, วัดขนาด DOM, ปรับ layout ของคุณ และปล่อยให้เบราว์เซอร์วาดภาพเพียงครั้งเดียว—อย่างถูกต้อง
