คุณรีเฟรชหน้าเว็บแล้วหน้าจอก็ยังคงว่างเปล่า หรือบางทีการคลิกปุ่มเพียงครั้งเดียวอาจทำให้พัดลม CPU ของคุณทำงานหนักจนเกินไป จากนั้นคอนโซลก็พ่นคำเตือนที่น่ากลัวออกมา: Maximum update depth exceeded. React ได้เหยียบเบรกอย่างแรงเพราะคอมโพเนนต์ของคุณติดอยู่ในลูปไม่สิ้นสุด (infinite loop) นี่คือหนึ่งในข้อผิดพลาดที่พบบ่อยที่สุดในแอปพลิเคชัน React และมักเกิดจากความเข้าใจผิดง่ายๆ เกี่ยวกับว่าโค้ดของคุณทำงานเมื่อใดกันแน่

เพื่อแก้ไขปัญหานี้ คุณต้องเข้าใจจังหวะที่แน่นอนว่าการเรนเดอร์ (render) กลายเป็นการเรนเดอร์ใหม่ (re-render) ได้อย่างไร และทำไมการเปลี่ยนแปลง state จึงต้องอยู่นอกเส้นทางการเรนเดอร์ (render path)

วิธีที่ React เรนเดอร์คอมโพเนนต์ของคุณ

React สร้างอินเทอร์เฟซผู้ใช้ขึ้นมาจากคอมโพเนนต์ ใน React ยุคใหม่ คอมโพเนนต์เหล่านั้นคือฟังก์ชัน ทุกครั้งที่ React จำเป็นต้องแสดงคอมโพเนนต์ของคุณบนหน้าจอ มันจะเรียกใช้ฟังก์ชันนั้น ภายในฟังก์ชัน คุณสามารถใช้ state เพื่อจดจำสิ่งต่างๆ ระหว่างการเรนเดอร์ State จะบอก React ว่าข้อมูลใดเป็นของคอมโพเนนต์ และที่สำคัญคือ เมื่อมีบางอย่างเปลี่ยนแปลงและ UI จำเป็นต้องอัปเดต

เมื่อ state เปลี่ยนแปลง React จะกำหนดตารางการเรนเดอร์ใหม่ ฟังก์ชันคอมโพเนนต์จะทำงานอีกครั้ง ส่งคืน JSX ใหม่ และ React จะอัปเดต DOM ให้ตรงกัน ในการใช้งานปกติ วงจรนี้ไม่มีอันตราย คุณคลิกปุ่ม ตัวจัดการเหตุการณ์ (event handler) อัปเดต state, React เรนเดอร์ใหม่หนึ่งครั้ง และผู้ใช้ก็จะเห็นข้อความหรือสีใหม่

ข้อผิดพลาดจะปรากฏขึ้นเมื่อการเรนเดอร์นั้นไปกระตุ้นให้เกิดการอัปเดต state อีกครั้ง การอัปเดต state ใหม่นั้นจะไปกระตุ้นการเรนเดอร์อีกครั้ง ซึ่งจะไปกระตุ้นการอัปเดต state อีกรอบ React จะยอมให้เกิดขึ้นได้เพียงไม่กี่สิบวงจร จากนั้นจะโยนข้อผิดพลาด maximum depth ออกมาเพื่อป้องกันไม่ให้เบราว์เซอร์ค้างไปโดยสิ้นเชิง

ดูเรื่อง State แบบเร็วๆ

ก่อนที่จะวิเคราะห์ลูปนี้ ลองนึกถึงวิธีการทำงานของ useState hook มันให้สิ่งที่คุณต้องการสองอย่างพอดี: ตัวแปรที่เก็บค่าปัจจุบัน และฟังก์ชันสำหรับเปลี่ยนค่านั้น

const MessageComponent = () => {
  const [message, setMessage] = useState('Welcome');

  return <h1>{message}</h1>;
};

ในที่นี้ message คือ 'Welcome' ในการเรนเดอร์ครั้งแรก หากคุณเรียก setMessage('Goodbye') ในภายหลัง React จะบันทึกการเปลี่ยนแปลง เรียก MessageComponent อีกครั้ง และตอนนี้ UI จะแสดงคำว่า "Goodbye" ทุกอย่างปกติดีเพราะไม่มีอะไรในตัวคอมโพเนนต์เองที่เรียกฟังก์ชัน setter โดยอัตโนมัติ ลูปจะเริ่มขึ้นเมื่อ setter ทำงานในระหว่างขั้นตอนการเรนเดอร์ (render phase) โดยไม่มีเหตุการณ์ภายนอกมากระตุ้น

การเรียก SetState โดยตรงใน Body ของคอมโพเนนต์

วิธีที่ตรงไปตรงมาที่สุดในการสร้างลูปไม่สิ้นสุดคือการเรียกฟังก์ชัน state setter ไว้ภายในตัวคอมโพเนนต์โดยตรง เนื่องจากตัวคอมโพเนนต์จะทำงานในทุกๆ การเรนเดอร์ ดังนั้น setter จึงทำงานในทุกๆ การเรนเดอร์ด้วย การอัปเดต state ใหม่นั้นจะทำให้เกิดการเรนเดอร์อีกครั้ง และวงจรนี้ก็จะหมุนไปตลอดกาล

นี่คือลักษณะของข้อผิดพลาด:

const Counter = () => {
  const [count, setCount] = useState(0);

  setCount(count + 1);

  return <div>{count}</div>;
};

ทุกครั้งที่ Counter เรนเดอร์ มันจะเพิ่มค่า count React จะเรนเดอร์อีกครั้งเพื่อแสดงตัวเลขใหม่ เห็น setCount(count + 1) อีกครั้ง และเพิ่มค่าขึ้นอีกครั้ง วิธีแก้ไขนั้นง่ายมาก: อย่าเรียก state setter ที่ระดับบนสุด (top level) ของคอมโพเนนต์ในระหว่างการเรนเดอร์ การอัปเดต state ควรตอบสนองต่อเหตุการณ์ของผู้ใช้ (user events) หรือ side effects ไม่ใช่ตอบสนองต่อการวาดหน้าจอ ให้ย้ายการอัปเดตนั้นไปไว้ใน event handler แทน:

const Counter = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

ข้อยกเว้นเพียงอย่างเดียวของการเรียก setter ระหว่างการเรนเดอร์คือเมื่อคุณกำลังคำนวณ state ใหม่จาก props และถึงอย่างนั้นคุณก็ควรใช้รูปแบบอื่น เช่น การหาค่าโดยตรง (deriving the value) หรือการใช้ useEffect อย่างตั้งใจ

การส่งการเรียกฟังก์ชันแทนการส่ง Reference

อีกสาเหตุที่พบบ่อยคือการพิมพ์ JSX ผิดพลาดเล็กน้อย เมื่อคุณแนบ event handler คุณจำเป็นต้องส่งตัวฟังก์ชันเอง หากคุณเผลอเรียกใช้ฟังก์ชันตรงนั้นใน JSX มันจะทำงานทันทีในระหว่างวงจรการเรนเดอร์

const Toggle = () => {
  const [isOn, setIsOn] = useState(false);

  const handleToggle = () => setIsOn(!isOn);

  return <button onClick={handleToggle()}>Toggle</button>;
};

การเขียน handleToggle() พร้อมวงเล็บ หมายความว่าคุณไม่ได้ให้ฟังก์ชันแก่ React เพื่อเรียกใช้ในภายหลังเมื่อผู้ใช้คลิก แต่คุณกำลังเรียกมัน เดี๋ยวนี้ ในขณะที่ React กำลังสร้าง virtual DOM เนื่องจาก handleToggle อัปเดต state คอมโพเนนต์จึงเรนเดอร์ใหม่ ในระหว่างการเรนเดอร์ใหม่นั้น React จะเห็น handleToggle() อีกครั้งและเรียกมันอีกครั้ง ลูปจึงไม่มีวันสิ้นสุด เวอร์ชันที่ถูกต้องคือการนำวงเล็บออก:

return <button onClick={handleToggle}>Toggle</button>;

หากคุณจำเป็นต้องส่งอาร์กิวเมนต์ ให้ครอบการเรียกนั้นด้วย anonymous function:

return <button onClick={() => handleToggle(true)}>Switch On</button>;

ความแตกต่างนี้มักทำให้แม้แต่นักพัฒนาที่มีประสบการณ์สับสนในระหว่างการ refactoring ดังนั้นควรระวังวงเล็บเหล่านั้นให้ดี

กับดัก Dependency ของ useEffect

Effects เป็นสถานที่ที่เหมาะสมสำหรับ side effects เช่น การดึงข้อมูล (fetching data), การซิงค์กับ browser APIs หรือการจัดการ DOM ด้วยตนเอง แต่ useEffect จะทำงานหลังจาก React ทำการ commit การเรนเดอร์ลงบนหน้าจอแล้ว หาก effect ของคุณอัปเดต state React จะเรนเดอร์ใหม่ โดยปกติแล้วจะไม่เป็นปัญหา แต่มันจะกลายเป็นลูปเมื่อ effect ทำงานหลังการเรนเดอร์ ทุกครั้ง และอัปเดต state เดิมเสมอ

พิจารณารูปแบบที่ผิดพลาดนี้:

const UserProfile = () => {
  const [user, setUser] = useState({});

  useEffect(() => {
    setUser({ name: 'Ada', role: 'Admin' });
  });

  return <div>{user.name}</div>;
};

เนื่องจากไม่มี dependency array ตัว effect นี้จึงทำงานหลังจากการ render ทุกครั้ง มันทำการตั้งค่า user ซึ่งไปกระตุ้นให้เกิดการ render และหลังจาก render นั้น effect ก็จะทำงานอีกครั้งและตั้งค่า user ซ้ำอีก React ตรวจพบวงจรที่วนลูปไม่สิ้นสุดนี้และแจ้งข้อผิดพลาดออกมา

วิธีแก้ไขคือการบอก React ว่า effect ควรจะทำงานเมื่อใด โดยการระบุ dependency array ที่เหมาะสม หากต้องการให้ effect ทำงานเพียงครั้งเดียวเมื่อ mount ให้ส่ง array ว่างเข้าไป:

useEffect(() => {
  setUser({ name: 'Ada', role: 'Admin' });
}, []);

หาก effect ขึ้นอยู่กับ prop หรือ state ให้ใส่เฉพาะตัวแปรนั้นลงใน array อย่างไรก็ตาม ต้องระวังด้วย การใส่ตัวแปรที่เปลี่ยนแปลงทุกครั้งที่มีการ render จะเป็นการสร้างลูปเดิมซ้ำอีกครั้งในรูปแบบอื่น ตัวอย่างเช่น หากคุณใส่ object literal ลงใน dependencies และ object นั้นถูกสร้างขึ้นใหม่ทุกครั้งที่ parent render ตัว effect ก็จะทำงานไปเรื่อยๆ อย่างไม่สิ้นสุด ในกรณีดังกล่าว คุณอาจจำเป็นต้องย้ายการสร้าง object ไปไว้นอก component หรือใช้การ memoize แทน

ขั้นตอนการ Debug ในทางปฏิบัติ

เมื่อคุณพบข้อผิดพลาดนี้ stack trace อาจดูน่าปวดหัวเพราะ React ได้ทำซ้ำวงจรนี้ไปหลายสิบครั้งแล้ว เริ่มต้นด้วยการอ่านส่วนบนสุดของ trace เพื่อดูว่า component ใดที่ถูกระบุชื่อซ้ำๆ จากนั้นให้มองหา state setter ใน 3 ตำแหน่งนี้:

  1. ส่วนหลักของ component ที่อยู่นอก handler หรือ hook ใดๆ
  2. attribute ของ event ใน JSX ที่คุณอาจเขียน handler() แทนที่จะเป็น handler
  3. hook useEffect ที่ไม่มี dependency array หรือขึ้นอยู่กับ reference ที่ไม่เสถียร (unstable references)

ลอง comment out state setter แต่ละตัวออกชั่วคราว จนกว่าข้อผิดพลาดจะหยุดลง นั่นจะบอกคุณได้อย่างชัดเจนว่าการ update ตัวไหนที่เป็นต้นเหตุ หาก setter อยู่ภายใน effect ให้ลองถามตัวเองว่าคุณจำเป็นต้องใช้ state ตรงนั้นจริงๆ หรือไม่ บางครั้งนักพัฒนาตั้งค่า local state จาก props ภายใน effect ทั้งที่จริงๆ แล้วสามารถใช้ prop นั้นใน JSX ได้โดยตรง

บทสรุปที่สำคัญ

ข้อผิดพลาด Maximum update depth ไม่ใช่บั๊กที่ลึกลับของ React แต่มันคือตาข่ายนิรภัย (safety net) มันหมายความว่า component ของคุณกำลังพยายาม re-render ตัวเอง แทนที่จะรอสัญญาณจากภายนอก จงเลิกนิสัยที่มองว่าการ render เป็นเหตุการณ์ (events) ที่ควรจะสร้าง state เพิ่มขึ้น จงมองว่าการ render คือผลลัพธ์ที่ตามมาจาก state ไม่ใช่สาเหตุ ให้เก็บการ update state ไว้ภายใน event handlers, callbacks หรือ effects ที่มีการเลือก dependencies อย่างรอบคอบ แล้วคุณจะไม่พบข้อผิดพลาดนี้อีกเลย