Angular forms ทำงานร่วมกับ HTML มาตรฐานได้อย่างยอดเยี่ยม ทั้ง input, textarea และ select ต่างก็สามารถนำมาใช้ใน Reactive Forms ได้ทันทีโดยไม่ต้องใช้ความพยายามเพิ่มเติม ตัวเฟรมเวิร์กเข้าใจทั้งเหตุการณ์ (events), ค่า (values) และสถานะ (states) ขององค์ประกอบเหล่านี้

แต่แอปพลิเคชันสมัยใหม่มักไม่สามารถทำงานได้ด้วยองค์ประกอบมาตรฐานเพียงอย่างเดียว คุณอาจต้องการ widget ให้คะแนนแบบดาว (star rating), ตัวเลือกวันที่แบบผสม (composite date selector) หรือตัวเลือกสีแบบกำหนดเอง (custom color picker) หากคุณนำสิ่งเหล่านี้ไปใส่ใน form group ทาง Angular จะมองว่ามันเป็นเพียง HTML ที่ไม่มีชีวิต patchValue จะไม่ทำงานอะไรเลย Validators ก็จะมองข้ามมันไป ฟอร์มจะไม่รู้เลยว่าผู้ใช้มีการโต้ตอบกับ control นั้นเมื่อใด และการใช้ form.disable() ก็จะยังคงปล่อยให้ custom widget นั้นสามารถโต้ตอบได้ตามปกติ

และนี่คือปัญหาที่ ControlValueAccessor ถูกสร้างขึ้นมาเพื่อแก้ไข

หน้าที่ที่แท้จริงของ ControlValueAccessor

ControlValueAccessor คือข้อตกลง (contract) ที่เปลี่ยน custom component ให้กลายเป็นส่วนหนึ่งของฟอร์มอย่างเต็มตัว (first-class form citizen) มันทำหน้าที่เป็นตัวแปลระหว่าง Angular Forms API กับ UI ของคุณเอง เมื่อคุณนำไปใช้งานอย่างถูกต้อง component ของคุณจะมีความแตกต่างจาก native input แทบไม่ได้เลยในมุมมองของฟอร์ม มันสามารถรับค่า, ส่งการเปลี่ยนแปลง (emit changes), รายงานการสัมผัส (report touches) และเคารพสถานะ disabled ได้เหมือนกับองค์ประกอบมาตรฐานที่ติดมากับระบบ

อินเทอร์เฟซนี้ต้องการสี่เมธอดที่เฉพาะเจาะจง โดยแต่ละเมธอดจะจัดการทิศทางการสื่อสารที่แตกต่างกัน

writeValue: จาก Form ไปยัง Component

writeValue(obj) คือช่องทางขาเข้า เมื่อใดก็ตามที่ form model มีการอัปเดตและจำเป็นต้องส่งค่าใหม่ไปยัง UI ของคุณ Angular จะเรียกใช้เมธอดนี้ หากคุณเรียกใช้ patchValue({ rating: 4 }) ใน form group ค่า 4 นั้นจะถูกส่งเข้าไปใน component ของคุณผ่าน writeValue หากคุณรีเซ็ตฟอร์ม writeValue จะได้รับค่าเริ่มต้นใหม่หรือ null หน้าที่ของคุณภายในเมธอดนี้คือการนำข้อมูลที่ได้รับมานั้นไปแมป (map) เข้ากับสถานะภายใน (internal state) ของ component หากคุณกำลังสร้าง color picker ตัว writeValue จะได้รับ hex string เช่น #ff4400 และคุณต้องอัปเดต view เพื่อแสดงสีนั้นเป็นสีที่ถูกเลือก

อย่างไรก็ตาม มีประเด็นในทางปฏิบัติที่ต้องระวัง Angular อาจเรียก writeValue ก่อนที่ view ของคุณจะถูกสร้างขึ้นอย่างสมบูรณ์ โดยเฉพาะอย่างยิ่งภายใน component ที่ถูกเรนเดอร์แบบไดนามิก, dialog หรืออินเทอร์เฟซแบบแท็บ หาก component ของคุณพยายามเข้าถึง DOM หรือ child components เร็วเกินไป คุณอาจพบข้อผิดพลาดขณะรันโปรแกรม (runtime errors) รูปแบบที่มั่นคงคือการเก็บค่าไว้ใน local property และนำไปใช้หลังจาก view เริ่มทำงานแล้ว หรือการป้องกันการอ้างอิงถึง child ที่ยังเป็น undefined อย่าทึกทักเอาเองว่า writeValue จะทำงานเฉพาะเมื่อ template ของคุณอยู่ในสถานะที่เสถียรแล้วเท่านั้น

registerOnChange: จาก Component ไปยัง Form

registerOnChange(fn) คือการตั้งค่าช่องทางขาออก Angular จะส่ง callback function ให้คุณ และคุณต้องเก็บการอ้างอิง (reference) ของฟังก์ชันนั้นไว้ ทุกครั้งที่ผู้ใช้เปลี่ยนค่าภายใน component ของคุณ คุณต้องเรียกฟังก์ชันนั้นพร้อมกับค่าใหม่ ใน component ให้คะแนนแบบดาว เมื่อผู้ใช้คลิกดาวดวงที่สาม คุณต้องเรียก callback ที่เก็บไว้ด้วยค่า 3 การเรียกนั้นจะไหลกลับไปยัง FormControl, อัปเดต model, กระตุ้นการสมัครรับข้อมูล (subscriptions) ของ valueChanges และสั่งให้ validators ทำงานใหม่อีกครั้ง

การข้ามขั้นตอนนี้เป็นวิธีที่พบบ่อยที่สุดที่ทำให้ฟอร์มพังโดยไม่แจ้งเตือน Widget อาจจะดูเหมือนทำงานได้ปกติ ผู้ใช้อาจเห็นดาวสว่างขึ้น สีเปลี่ยนไป หรือวันที่ถูกกรอกลงไป แต่ form model กลับไม่เคยอัปเดตเลย Validators ยังคงประเมินข้อมูลที่ล้าสมัย (stale data) ตัวจัดการการส่งฟอร์ม (submit handlers) ก็จะส่งค่าเก่าออกไป ตัว component ดูเหมือนจะทำงานได้ แต่ในความเป็นจริงแล้วฟอร์มกลับ "ตาบอด" หาก custom control ของคุณรับข้อมูลจากผู้ใช้ได้แต่ฟอร์มที่ล้อมรอบอยู่กลับไม่รับรู้เลย นี่คือสาเหตุที่แทบจะแน่นอนที่สุด

registerOnTouched: การรายงานการโต้ตอบ

ฟอร์มไม่ได้ติดตามแค่ค่าเท่านั้น แต่ยังติดตามด้วยว่าผู้ใช้ได้มีการโต้ตอบกับฟิลด์นั้นแล้วหรือยัง Angular ใช้สถานะ touched เพื่อตัดสินใจว่าเมื่อใดจึงจะเหมาะสมในการแสดงข้อผิดพลาดจากการตรวจสอบ (validation errors) ตัวอย่างเช่น ช่องกรอกข้อความที่จำเป็น (required text input) ไม่ควรขึ้นสีแดงทันทีที่โหลดหน้าเว็บ แต่ควรจะรอจนกว่าผู้ใช้จะกด tab ออกไปหรือคลิกที่อื่น

Native inputs จัดการเรื่องนี้โดยอัตโนมัติผ่าน blur events แต่ custom components ไม่ได้เป็นเช่นนั้น คุณต้องใช้ registerOnTouched(fn) เพื่อรายงานการโต้ตอบเหล่านี้ด้วยตัวเอง Angular จะให้ callback อีกตัวหนึ่งมา ซึ่งคุณต้องเรียกใช้เมื่อคุณตัดสินใจว่าผู้ใช้ได้มีการโต้ตอบกับ control นั้นอย่างมีความหมายแล้ว

จังหวะเวลาที่แน่นอนนั้นขึ้นอยู่กับ component ของคุณ สำหรับ custom input ที่คล้ายกับข้อความ คุณอาจเรียกใช้เมื่อเกิด blur สำหรับการให้คะแนนแบบดาว การคลิกครั้งแรกน่าจะเป็นจังหวะที่เหมาะสมที่สุด สำหรับ color picker ที่เปิด popover ขึ้นมา คุณอาจรอจนกว่าพาเลทสีจะปิดลง หัวใจสำคัญคือความสม่ำเสมอ หากคุณไม่เคยเรียก touched callback เลย Angular จะยังคงทำเครื่องหมายว่า control นั้นอยู่ในสถานะ pristine ข้อผิดพลาดจากการตรวจสอบจะยังคงถูกซ่อนไว้แม้ว่าผู้ใช้จะแก้ไขเสร็จสิ้นอย่างชัดเจนแล้วก็ตาม ซึ่งจะนำไปสู่ความสับสนและประสบการณ์การใช้งานที่ไม่ดี

setDisabledState: การตอบสนองต่อคำสั่งของฟอร์ม

ฟอร์มแบบไดนามิกจะมีการเปิดและปิดการใช้งานฟิลด์ต่างๆ อยู่ตลอดเวลาตาม business logic เมื่อคุณเรียกใช้ .disable() บน FormControl Angular ต้องการให้คอมโพเนนต์ที่คุณสร้างขึ้นตอบสนองต่อสิ่งนั้น โดย setDisabledState(isDisabled) จะรับค่าเป็น boolean เมื่อค่าเป็น true คุณควรล็อกการใช้งาน UI ของคุณ

นี่หมายถึงการทำมากกว่าแค่การเพิกเฉยต่อการคลิก คุณควรปิดการใช้งานปุ่มภายใน, ลบสถานะที่สามารถโฟกัสได้ (focusable states) และใช้การปรับแต่งทางภาพ เช่น การลดความโปร่งใส (opacity) หรือใช้ pointer-events: none หากคุณเพิกเฉยต่อเมธอดนี้ คอมโพเนนต์ของคุณจะยังคงโต้ตอบได้เต็มที่ในขณะที่โมเดลของฟอร์มยืนยันว่ามันถูกปิดใช้งานอยู่ ซึ่งจะทำให้เกิดบั๊กที่ไล่ตามได้ยาก ผู้ใช้สามารถแก้ไขค่าที่ฟอร์มควรจะปฏิเสธ ปุ่มบันทึกอาจเปิดใช้งานตามสถานะที่ไม่ถูกต้อง ทำให้ form group และ UI ไม่สอดคล้องกัน

คอนโทรลที่สร้างขึ้นอย่างดีจะถือว่า setDisabledState เป็นข้อกำหนดหลัก ไม่ใช่สิ่งที่ทำเพิ่มทีหลัง

ข้อผิดพลาดที่จะทำให้คุณเสียเวลาในการดีบั๊ก

มีข้อผิดพลาดซ้ำๆ หลายอย่างที่มักจะทำให้เหล่านักพัฒนาที่เพิ่งเริ่มใช้งาน interface นี้ต้องติดขัด

ลืมเรียกใช้ change callback คอมโพเนนต์ของคุณอัปเดตสถานะภายในแล้ว แต่ฟอร์มไม่ได้รับทราบเรื่องนั้น ตัวตรวจสอบ (Validators) จะหยุดชะงัก และฟอร์มหลักจะส่งข้อมูลที่ล้าสมัย (stale data) ออกไป เสมอ ดังนั้นควรเรียกใช้ฟังก์ชัน onChange ที่เก็บไว้ทันทีที่ผู้ใช้ยืนยันค่าใหม่

ข้าม touched callback หากไม่มีสิ่งนี้ Angular จะไม่ทำเครื่องหมายว่าคอนโทรลนั้นถูกแตะต้องแล้ว (touched) ข้อความแสดงข้อผิดพลาดที่ผูกกับสถานะ touched หรือ dirty จะไม่แสดงผล ผู้ใช้จะจ้องมองฟอร์มที่ดูเหมือนจะถูกต้องแต่ไม่ยอมส่งข้อมูล โดยไม่มีการระบุให้เห็นว่ามีอะไรผิดพลาด

ละเลยสถานะ disabled คอนโทรลที่ดูเหมือนเปิดใช้งานอยู่ทางสายตา แต่ฟอร์มคิดว่าถูกปิดใช้งาน จะสร้างขอบเขตความเชื่อมั่นที่พังทลาย ผู้ใช้สามารถพิมพ์หรือคลิกต่อไปได้ แต่โมเดลจะเพิกเฉยต่อพวกเขา หรือที่แย่กว่านั้น โมเดลอาจเขียนทับอินพุตของพวกเขาเป็นระยะๆ ในระหว่างรอบการซิงค์ข้อมูล

ละเลยการใส่ NG_VALUE_ACCESSOR provider นี่คือตัวฆ่าเงียบ หากคุณเขียนเมธอดทั้งสี่แต่ลืมเพิ่ม NG_VALUE_ACCESSOR ลงในอาร์เรย์ providers ของคอมโพเนนต์ Angular จะไม่ลงทะเบียนคอมโพเนนต์ของคุณเป็น value accessor โค้ดคอมไพล์ผ่าน วิว (view) แสดงผล แต่ไม่มีการผูกข้อมูล (binding) ไม่มีการแจ้งข้อผิดพลาด มีเพียงคอมโพเนนต์ที่ลอยอยู่นอกฟอร์มโดยสิ้นเชิง ให้ใส่ไว้ใน decorator metadata เสมอ

Signals, Validators และ Angular ยุคใหม่

ControlValueAccessor ไม่ใช่ API รุ่นเก่า แต่มันเข้ากันได้อย่างลงตัวกับการพัฒนา Angular ยุคใหม่ ไม่ว่าคุณจะจัดการสถานะภายในด้วย Signals, property ทั่วไป หรือ RxJS subjects เมธอดทั้งสี่จะยังคงเป็นสัญญา (contract) สาธารณะของคุณกับโมดูลฟอร์ม คุณรับค่าใน writeValue เปลี่ยนแปลงค่าใน Signals หรือสถานะของคุณ และส่งออกผ่าน callback ที่ Angular เตรียมไว้ให้

Validators มาตรฐานทำงานได้โดยไม่ต้องแก้ไข Validators.required, Validators.min, Validators.pattern และ custom cross-field validators ทั้งหมดจะประเมินคอมโพเนนต์ที่ใช้ CVA ของคุณเหมือนกับที่ทำกับ input พื้นฐาน ฟอร์มคอนโทรลจะมองเห็นเพียงค่าและสถานะ โดยไม่สนใจว่าค่านั้นมาจาก text box หรือตัวเลือกเดือน (month-picker) ที่สร้างขึ้นเอง

ความสามารถในการเคลื่อนย้าย (portability) นี้เองคือเหตุผลที่ CVA สำคัญต่อ design systems และ UI libraries ที่ใช้ร่วมกัน ทีมหนึ่งสร้างอินพุตเบอร์โทรศัพท์ที่แข็งแกร่งหรือ widget อัปโหลดไฟล์ พวกเขาเขียน interface เพียงครั้งเดียว ทีมอื่นๆ ในองค์กรสามารถนำไปใส่ใน Reactive Forms ของพวกเขาได้ทันทีโดยไม่ต้องเชื่อมต่อเพิ่มเติม คอมโพเนนต์จะทำงานได้อย่างคาดเดาได้ ตรวจสอบความถูกต้องได้อย่างสม่ำเสมอ และปิดการใช้งานได้อย่างคงเส้นคงวาในทุก feature module

บทสรุปที่แท้จริง

ControlValueAccessor ไม่ใช่แค่ interface อีกตัวที่ต้องท่องจำเพื่อไปตอบคำถามสัมภาษณ์ แต่มันคือสะพานที่ช่วยให้คอมโพเนนต์ที่คุณสร้างขึ้นสามารถมีส่วนร่วมในระบบนิเวศของฟอร์มใน Angular ได้อย่างเท่าเทียมกับ HTML elements พื้นฐาน การเชี่ยวชาญมันหมายถึงการเข้าใจการสื่อสารทั้งหมดระหว่าง widget ของคุณกับฟอร์ม: การรับค่า, การรายงานการเปลี่ยนแปลง, การแจ้งสถานะการแตะ (touches) และการเคารพสถานะ disabled หากคุณทำทั้งสี่ส่วนนี้ให้ถูกต้อง คุณจะสามารถสร้างฟอร์มคอนโทรลที่ซับซ้อนและนำกลับมาใช้ใหม่ได้ ซึ่งจะให้ความรู้สึกราบรื่นจนนักพัฒนาที่นำไปใช้แทบไม่รู้สึกถึงความซับซ้อน นั่นคือเครื่องหมายของคอมโพเนนต์ Angular ระดับมืออาชีพ