วิธีจัดโครง component React ไม่ให้เกิด stale closure

handler ทุกตัวเห็น state ของ render ที่สร้างมันขึ้นมาเท่านั้น จึงอ่านค่าเก่าได้ถ้าเรียกหลัง setState ทางกันคือส่งค่าใหม่ไปกับ event ใช้ setState แบบฟังก์ชัน วาง handler ไว้ใกล้ state ที่มันอ่าน ให้ hook รับค่าเป็น argument และจำไว้ว่า effect ของ child รันก่อน parent เวลาย้าย hook ข้าม component ลำดับจึงเปลี่ยนได้

เผยแพร่
เวลาอ่าน
5 นาที

หมวด 3 · Componentบทที่ 11 จาก 14

สารบัญบทเรียน บทที่ 11 จาก 14
ในบทนี้ 9 หัวข้อ
  1. stale closure คืออะไร
  2. อาการที่บอกว่ามี stale closure รออยู่
  3. ก่อนและหลัง
  4. ทางแก้ที่ใช้ได้
  5. วาง handler ไว้ใกล้ state ที่มันอ่าน
  6. dependency array: เติมอะไรได้ เติมอะไรไม่ได้
  7. effect ของ child รันก่อน parent
  8. เมื่อไหร่ไม่ต้องกังวล
  9. จากงานจริง

บทนี้ต่างจากบทอื่นในหมวด Component ตรงที่ไม่ได้สอน pattern ใหม่ แต่สอนว่าการวางโค้ดแบบไหนทำให้ bug สองแบบนี้เกิดยาก bug ทั้งสองแบบไม่มี error ไม่ทำให้ test แดง และมักโผล่ตอนที่เราแยก component ใหญ่ออกเป็นชิ้น ๆ ตามบทก่อนหน้า

stale closure คืออะไร

ทุกครั้งที่ component render ฟังก์ชันที่ประกาศในนั้นจะถูกสร้างใหม่ และจำค่าของ props กับ state ณ render นั้น ไว้ เรียกว่า closure ปกติไม่มีปัญหา เพราะพอ state เปลี่ยน component render ใหม่ handler ก็ถูกสร้างใหม่พร้อมค่าใหม่

ปัญหาเกิดเมื่อเราเรียก handler ตัวเก่าหลังจากสั่ง setState ไปแล้ว แต่ก่อนที่ component จะ render ใหม่ handler ตัวเก่ายังเห็นค่าเดิมอยู่ เพราะ setState ไม่ได้เปลี่ยนตัวแปรทันที แค่ขอให้ React render ใหม่ด้วยค่าใหม่ เอกสารของ React อธิบายเรื่องนี้ว่า state เป็นเหมือนภาพถ่ายของ render หนึ่ง

อาการที่บอกว่ามี stale closure รออยู่

  • setTimeout(() => save(), 0) ต่อจาก setState เพื่อรอให้ state อัปเดตก่อนค่อยบันทึก ใช้ไม่ได้ เพราะ save ตัวนั้นยังเป็นตัวเก่า
  • dependency array ของ effect ขาดค่าที่ effect อ่าน พร้อมคอมเมนต์ // eslint-disable-next-line react-hooks/exhaustive-deps เหนือบรรทัด
  • handler ที่ส่งต่อลงไปสี่ชั้น แล้วชั้นล่างสุดเรียกมันด้วย argument ที่ประกอบมาจากที่อื่น
  • bug ที่เกิดแค่ตอนกดเร็ว ๆ หรือตอนกดครั้งแรกหลังเปลี่ยนค่า กดซ้ำอีกครั้งแล้วถูก

ก่อนและหลัง

ตัวอย่างนี้เป็นหน้าเลือกขนาดสินค้า พอผู้ใช้กดเลือกขนาด ให้บันทึกลงตะกร้าทันที

// ก่อน: กดเลือกแล้วบันทึกขนาดที่เลือกไว้ก่อนหน้า
function SizeField({ values, onChange, onSave }: SizeFieldProps) {
  return (
    <SizeCards
      onPick={(size) => {
        onChange('size', size);
        setTimeout(() => onSave(), 0);
        // onSave อ่าน values.size จาก closure ของ render นี้ ซึ่งยังเป็นขนาดเดิม
      }}
    />
  );
}

// หลัง: event พาค่าใหม่ไปด้วย ไม่ต้องรอ render รอบใหม่
function SizeField({ onChange, onSave }: SizeFieldProps) {
  return (
    <SizeCards
      onPick={(size) => {
        onChange('size', size);
        onSave({ size });
      }}
    />
  );
}

ก่อนแก้ ทุกอย่างดูถูก บันทึกได้ ไม่มี error test ที่เช็กว่า "กดเลือกแล้วมีการบันทึก" ก็ผ่าน แต่ขนาดที่บันทึกเป็นขนาดก่อนหน้าที่ผู้ใช้เลือก ผู้ใช้กดเลือก M ตะกร้าได้ S

หลังแก้ ค่าที่ผู้ใช้เพิ่งเลือกเดินทางไปกับ event ตรง ๆ onSave ไม่ต้องไปอ่าน state เลย จึงไม่มีทางได้ค่าเก่า

ทางแก้ที่ใช้ได้

ตัวอย่างข้างบนเป็นทางที่ง่ายที่สุด แต่มีอีกหลายทาง เลือกตามสถานการณ์

ส่งค่าใหม่ไปกับ event แบบในตัวอย่าง ใช้ได้เมื่อ handler รู้ค่าใหม่อยู่แล้ว ซึ่งเป็นกรณีส่วนใหญ่

setState แบบส่งฟังก์ชัน เมื่อค่าใหม่คำนวณจากค่าเก่า เช่น เพิ่มจำนวนสินค้า ให้เขียน setQty((q) => q + 1) แทน setQty(qty + 1) React จะส่งค่าล่าสุดเข้ามาให้ ไม่ต้องอ่านจาก closure กดเร็วแค่ไหนก็นับครบ

เก็บค่าล่าสุดไว้ใน ref เมื่อ callback ต้องอ่านค่าล่าสุดแต่ไม่ได้เป็นคนเปลี่ยนค่า เช่น auto-save ที่รันทุกสามสิบวินาที ให้อัปเดต ref.current ใน handler เดียวกับที่ setState แล้วให้ตัวบันทึกอ่านจาก ref

useEffectEvent (React 19.2) เมื่อ effect ต้องเรียกฟังก์ชันที่อ่านค่าล่าสุด แต่ไม่อยากให้ effect รันใหม่ทุกครั้งที่ค่านั้นเปลี่ยน เช่น effect ที่ต่อ websocket ตาม roomId แล้วต้องแสดงแจ้งเตือนตาม theme ปัจจุบัน Separating Events from Effects อธิบายกรณีนี้ไว้ละเอียด

วาง handler ไว้ใกล้ state ที่มันอ่าน

ทางแก้ทั้งหมดข้างบนเป็นการแก้ปลายเหตุ ต้นเหตุมักเป็นโครงของ component คือ handler อยู่ไกลจาก state ที่มันอ่าน

ถ้า state อยู่ที่ parent แล้ว handler ถูกส่งลงไปสี่ชั้น ชั้นล่างสุดที่เรียก handler ไม่รู้เลยว่า handler อ่านอะไร และไม่รู้ว่าค่าที่อ่านจะเก่าหรือใหม่ พอมีคนเพิ่ม setTimeout หรือเรียก handler สองตัวต่อกัน bug ก็เกิด

โครงที่ดีกว่ามีสองข้อ

  • handler อยู่ที่เดียวกับ state ถ้าจะส่งอะไรลงไป ให้ส่ง handler ที่สมบูรณ์แล้ว ไม่ใช่ส่ง setter ลงไปให้ชั้นล่างประกอบเอง ชั้นล่างควรเรียกแค่ onPick(size) แล้วจบ แบบที่บทcomponent ย่อยรับ props ส่ง callback ออกว่าไว้
  • hook รับค่าเป็น argument custom hook ที่ต้องใช้ค่าจาก component ให้รับเป็น argument ตรง ๆ เช่น useAutoSave(draft) ไม่ใช่ให้ hook ไปเรียก callback ที่ปิดค่าไว้จาก component สามชั้นข้างบน ค่าที่เข้ามาเป็น argument จะใหม่ทุก render เสมอ

dependency array: เติมอะไรได้ เติมอะไรไม่ได้

กฎ react-hooks/exhaustive-deps เตือนเมื่อ effect อ่านค่าที่ไม่อยู่ใน dependency array คำเตือนนี้ควรทำตามแทบทุกครั้ง แต่ต้องรู้ว่าเติมอะไรแล้วปลอดภัย

เติมเข้าไป ผล
setter จาก useState หรือ dispatch จาก useReducer ไม่เปลี่ยนเลยตลอดชีวิตของ component เติมได้ ผลเหมือนเดิม
ref ที่ได้จาก useRef ตัว ref ไม่เปลี่ยน เติมได้
ค่า primitive อย่าง id หรือ string effect รันใหม่เมื่อค่าเปลี่ยน ซึ่งมักเป็นสิ่งที่ต้องการ
object หรือ array ที่สร้างใหม่ใน render effect รันใหม่ทุก render ไม่ควรเติมตรง ๆ

แถวสุดท้ายคือกับดัก ถ้า effect อ่าน options ที่เป็น object ซึ่งสร้างใหม่ทุก render ให้ย้ายการสร้าง object เข้าไปไว้ใน effect หรือใช้แค่ field ที่ต้องใช้เป็น dependency แทนทั้ง object

ส่วน eslint-disable เหนือ dependency array แทบทุกครั้งคือ stale closure ที่ยังไม่มีใครเจอ ถ้าเจอในโค้ด ให้ถามว่า effect นี้ควรเป็น event handler ไหม หรือควรใช้ useEffectEvent

effect ของ child รันก่อน parent

เรื่องที่สองของบทนี้คือลำดับของ effect หลัง render เสร็จ React จะรัน effect จากล่างขึ้นบน คือ effect ของ child รันก่อน effect ของ parent และในแต่ละ component รันตามลำดับที่เขียนไว้

ลำดับนี้ไม่ค่อยสำคัญ จนวันที่เราแยก component ใหญ่เป็นชิ้น ๆ สมมติว่า component หนึ่งมีสอง effect

  1. effect แรกอ่านค่าเริ่มต้นจาก URL แล้วใส่ลงใน state
  2. effect ที่สองโหลดข้อมูลตาม state นั้น

ถ้าเราย้าย effect แรกไปไว้ใน component ลูกที่เพิ่งแยกออกมา effect แรกจะยังรันก่อน แต่ถ้าย้าย effect ที่สองไปไว้ในลูกแทน มันจะรันก่อน effect แรก และโหลดข้อมูลด้วยค่าเริ่มต้นที่ยังไม่ได้อ่านจาก URL ผลคือโหลดข้อมูลสองรอบ หรือแย่กว่านั้นคือแสดงข้อมูลผิดชั่วครู่

effect ที่ซ่อนอยู่ใน hook ก็นับด้วย hook อย่าง SWR หรือ useCopyToClipboard มี effect อยู่ข้างใน ย้ายการเรียก hook ไปที่ไหน effect ของมันก็ไปด้วย

state ที่ย้ายลงไปอยู่ในลูกอาจหายได้

อีกเรื่องที่คล้ายกัน คือการย้าย useState จาก parent ลงไปไว้ในลูก state จะอยู่นานเท่าเดิมก็ต่อเมื่อลูกถูก render อยู่ตลอด ถ้าลูกถูกซ่อนด้วยเงื่อนไข เช่น อยู่ใน tab ที่ไม่ได้เปิด หรือมี key ที่เปลี่ยนได้ state จะหายทุกครั้งที่ลูกหายไปจากจอ ผู้ใช้กรอกฟอร์มใน tab หนึ่ง สลับ tab แล้วกลับมา ข้อมูลหาย

เวลาย้าย hook ให้จดลำดับก่อนและหลัง

วิธีที่ใช้ได้จริงตอนแยก component คือเขียนรายการลำดับ effect ก่อนแยกไว้ แล้วเขียนอีกครั้งหลังแยก ถ้าเปลี่ยน ต้องมีเหตุผล การแยกส่วนใหญ่ควรทำให้ลำดับเหมือนเดิม ถ้าเปลี่ยนโดยตั้งใจ ให้เป็น commit แยกที่บอกชัดว่าเปลี่ยนเพราะอะไร

เมื่อไหร่ไม่ต้องกังวล

  • handler ที่แค่เรียก setState แล้วจบ ไม่มีอะไรต่อจากนั้น ไม่มี stale closure ให้กลัว
  • effect ที่ไม่ได้ขึ้นกับกัน เช่น effect หนึ่งตั้ง title ของหน้า อีกตัวต่อ analytics ลำดับสลับกันได้โดยไม่มีผล ไม่ต้องจดทุก effect ให้จดเฉพาะตัวที่อ่านค่าที่อีกตัวเขียน
  • อย่ารีบใส่ ref ทุกที่ ref แก้ stale closure ได้ แต่ทำให้อ่านโค้ดยากขึ้น เพราะค่าใน ref เปลี่ยนแล้ว component ไม่ render ใหม่ ลองส่งค่าไปกับ event ก่อนเสมอ

จากงานจริง

ร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เราแยก component ใหญ่ไปราว 70 ไฟล์ ทุกรอบตอนรีวิวต้องดูลำดับ effect ก่อนและหลังแยก มี dependency array ราวสิบที่ที่ต้องเติม setter หรือ ref เข้าไปให้ครบ ซึ่งเติมแล้วผลเหมือนเดิม

ระหว่างทางยังเจอ stale closure จริงในโค้ดเดิมสองจุด จุดหนึ่งเป็นแบบเดียวกับตัวอย่างในบทนี้ คือบันทึกค่าเก่าเพราะเรียกหลัง setState ในรอบเดียวกัน ทั้งสองจุดเราจดไว้แก้แยก ไม่ได้แก้ในการแยกไฟล์ครั้งนั้น เพราะการแยกไฟล์ควรย้ายโค้ดอย่างเดียว ถ้าแก้ไปด้วยกัน คนรีวิวจะแยกไม่ออกว่าพฤติกรรมที่เปลี่ยนมาจากการแก้ bug หรือจากการย้ายผิด

บทถัดไป

อ่านต่อ

ล่าสุด

ดูทั้งหมด →