טפסי Angular עובדים בצורה נפלאה עם HTML סטנדרטי. input, textarea, ו-select משתלבים בתוך Reactive Forms ללא מאמץ נוסף. הפריימוורק מבין את האירועים שלהם, את הערכים שלהם ואת המצבים שלהם.

אך אפליקציות מודרניות כמעט ולא מסתפקות באלמנטים סטנדרטיים בלבד. ייתכן שתזדקקו לווידג'ט דירוג בכוכבים, בורר תאריך מורכב או בורר צבעים (color picker) מותאם אישית. אם תכניסו אחד מאלה לתוך form group, Angular יתייחס אליו כאל HTML "מת". patchValue לא יעשה דבר. ה-Validators יתעלמו ממנו. לטופס אין מושג מתי המשתמש מתקשר עם ה-control, ו-form.disable() ישאיר את הווידג'ט המותאם אישית אינטראקטיבי לחלוטין.

זו בדיוק הבעיה ש-ControlValueAccessor נועד לפתור.

מה ControlValueAccessor באמת עושה

ControlValueAccessor הוא ה"חוזה" (contract) שהופך קומפוננטה מותאמת אישית לאזרח מסוג ראשון בתוך הטופס. הוא פועל כמתרגם בין ה-Angular Forms API לבין ה-UI שלכם. ברגע שתממשו אותו כראוי, הקומפוננטה שלכם תהיה בלתי ניתנת להבחנה מ-input טבעי מנקודת המבט של הטופס. היא תוכל לקבל ערכים, לשדר שינויים, לדווח על מגע (touches) ולכבד מצבי disabled בדיוק כמו אלמנט מובנה.

הממשק דורש ארבעה מתודות ספציפיות. כל אחת מהן מטפלת בכיוון תקשורת שונה.

writeValue: מהטופס לקומפוננטה

writeValue(obj) הוא הנתיב הנכנס. בכל פעם שמודל הטופס מתעדכן וצריך לדחוף ערך חדש לתוך ה-UI שלכם, Angular קוראת למתודה זו. אם תפעילו patchValue({ rating: 4 }) על form group, הערך 4 יגיע אל תוך הקומפוננטה שלכם דרך writeValue. אם תאפסו את הטופס, writeValue תקבל את הערך ההתחלתי החדש או null. התפקיד שלכם בתוך המתודה הזו הוא לקחת את הנתונים הנכנסים ולמפות אותם למצב הפנימי (internal state) של הקומפוננטה. אם אתם בונים color picker, writeValue תקבל מחרוזת hex כמו #ff4400, ועליכם לעדכן את ה-view כדי להציג את הצבע הזה כנבחר.

יש כאן בעיה מעשית אחת. Angular יכולה לקרוא ל-writeValue לפני שה-view שלכם אותחל במלואו, במיוחד בתוך קומפוננטות שמוצגות באופן דינמי, דיאלוגים או ממשקי טאבים. אם הקומפוננטה שלכם תנסה לגשת ל-DOM או לקומפוננטות ילד מוקדם מדי, אתם עלולים להיתקל בשגיאות runtime. תבנית (pattern) אמינה היא לשמור את הערך ב-property מקומי וליישם אותו לאחר שה-view אותחל, או להגן מפני הפניות לילדים שהם undefined. לעולם אל תניחו ש-writeValue פועלת רק כשהתבנית (template) שלכם יציבה.

registerOnChange: מהקומפוננטה לטופס

registerOnChange(fn) מגדיר את הנתיב היוצא. Angular מעבירה לכם פונקציית callback, ועליכם לשמור לה הפניה (reference). בכל פעם שהמשתמש משנה את הערך בתוך הקומפוננטה שלכם, אתם קוראים לפונקציה הזו עם הערך החדש. בקומפוננטת דירוג בכוכבים, כאשר המשתמש לוחץ על הכוכב השלישי, אתם מפעילים את ה-callback שנשמר עם הערך 3. הקריאה הזו זורמת חזרה אל ה-FormControl, מעדכנת את המודל, מפעילה כל subscription של valueChanges ומריצה מחדש את ה-validators.

דילוג על השלב הזה הוא הדרך הנפוצה ביותר לשבור טופס בשקט. הווידג'ט עשוי להיראות פעיל. המשתמש רואה כוכבים נדלקים, צבעים משתנים או תאריכים מתמלאים. אך מודל הטופס לעולם לא מתעדכן. ה-Validators ממשיכים להעריך נתונים מיושנים (stale data). פונקציות ה-Submit שולחות ערכים ישנים. הקומפוננטה נראית כעובדת, אך הטופס למעשה "עיוור". אם ה-control המותאם אישית שלכם מקבל קלט מהמשתמש אך הטופס שמסביב לעולם לא מבחין בכך, זה כמעט תמיד הגורם לכך.

registerOnTouched: דיווח על אינטראקציה

טפסים לא רק עוקבים אחר ערכים. הם עוקבים אחר השאלה האם המשתמש בא במגע עם שדה מסוים. Angular משתמשת במצב ה-touched כדי להחליט מתי זה מתאים להציג שגיאות ולידציה. שדה טקסט חובה לא אמור להבהב באדום ברגע שהדף נטען. הוא צריך לחכות עד שהמשתמש יעבור לשדה הבא (tab away) או ילחץ במקום אחר.

קלטים (inputs) טבעיים מטפלים בכך אוטומטית באמצעות אירועי blur. קומפוננטות מותאמות אישית לא. עליכם להשתמש ב-registerOnTouched(fn) כדי לדווח על האינטראקציות הללו בעצמכם. Angular נותנת לכם callback נוסף; אתם קוראים לו כשאתם מחליטים שהמשתמש בא במגע משמעותי עם ה-control.

התזמון המדויק תלוי בקומפוננטה שלכם. עבור קלט מותאם אישית דמוי טקסט, ייתכן שתקראו לו ב-blur. עבור דירוג בכוכבים, הלחיצה הראשונה היא כנראה הרגע הנכון. עבור color picker שפותח popover, אולי תמתינו עד שהפלטה תיסגר. המפתח הוא עקביות. אם לעולם לא תקראו ל-callback של ה-touched, Angular תמשיך לסמן את ה-control כ-pristine. שגיאות ולידציה יישארו מוסתרות גם לאחר שהמשתמש סיים בבירור לערוך. זה מוביל לבלבול ולחוויית משתמש גרועה.

setDisabledState: כיבוד פקודות הטופס

טפסים דינמיים מאפשרים ומנטרלים שדות באופן קבוע בהתאם ללוגיקה עסקית. כשאתה קורא ל-.disable() על FormControl, Angular זקוק לכך שהקומפוננטה המותאמת אישית שלך תגיב. setDisabledState(isDisabled) מקבלת ערך בוליאני. כאשר הערך הוא true, עליך לנעול את ממשק המשתמש (UI) שלך.

המשמעות היא יותר מאשר רק התעלמות מקליקים. עליך לנטרל כפתורים פנימיים, להסיר מצבי פוקוס (focusable states), ולהחיל עיבודים ויזואליים כמו שקיפות מופחתת או pointer-events: none. אם תתעלם ממתודה זו, הקומפוננטה שלך תישאר אינטראקטיבית לחלוטין בעוד שמודל הטופס מתעקש שהיא מנוטרלת. זה יוצר באגים שקשה לעקוב אחריהם. משתמשים יכולים לשנות ערכים שהטופס אמור לדחות. כפתורי שמירה עשויים להפוך לפעילים על בסיס מצבים לא תקינים. ה-form group וה-UI מתנתקים זה מזה.

קונטרול (control) מותאם אישית שנבנה היטב מתייחס ל-setDisabledState כדרישת יסוד, ולא כדבר משני.

טעויות שיעלו לך זמן דיבאגינג

מספר טעויות חוזרות ונשנות מבלבלות מפתחים שחדשים לממשק זה.

שכחה של קריאה ל-change callback. הקומפוננטה שלך מעדכנת את המצב הפנימי שלה, אך הטופס לעולם לא שומע על כך. ה-Validators נתקעים, וטפסים אב שולחים נתונים לא מעודכנים (stale data). תמיד הפעל את פונקציית ה-onChange השמורה ברגע שהמשתמש מאשר ערך חדש.

דילוג על ה-touched callback. בלעדיו, Angular לעולם לא מסמן את ה-control כ-"touched". הודעות שגיאה הקשורות למצבי touched או dirty לא יופיעו. משתמשים יביטו בטופס שנראה תקין אך לא יתאפשר לשלוח אותו, ללא שום אינדיקציה נראית לעין למה בדיוק לא בסדר.

הזנחת מצב ה-disabled. קונטרול שנראה פעיל ויזואלית אך הטופס חושב שהוא מנוטרל יוצר גבול אמון שבור. המשתמש יכול להמשיך להקליד או ללחוץ, אך המודל מתעלם ממנו. או גרוע מכך, המודל דורס את הקלט שלו באופן לא עקבי במהלך מחזורי סנכרון.

השמטת ה-NG_VALUE_ACCESSOR provider. זהו "הרוצח השקט". אם אתה מממש את ארבע המתודות אך שוכח להוסיף את ה-NG_VALUE_ACCESSOR למערך ה-providers של הקומפוננטה שלך, Angular לעולם לא ירשום את הקומפוננטה שלך כ-value accessor. הקוד עובר קומפילציה. התצוגה (view) מרונדרת. שום דבר לא נקשר (binds). אין הודעת שגיאה, רק קומפוננטה שצפה לחלוטין מחוץ לטופס. תמיד כלול אותו ב-decorator metadata.

Signals, Validators, ו-Angular מודרנית

ControlValueAccessor אינו ממשק API מיושן (legacy). הוא משתלב בצורה חלקה בפיתוח Angular מודרני. בין אם אתה מנהל מצב פנימי באמצעות Signals, מאפיינים רגילים (plain properties), או RxJS subjects, ארבע המתודות נותרות החוזה הציבורי שלך עם מודול הטפסים. אתה צורך ערכים ב-writeValue, משנה את ה-Signals או המצב שלך, ומשדר (emit) דרך ה-callbacks ש-Angular מספקת.

Validators סטנדרטיים עובדים ללא שינוי. Validators.required, Validators.min, Validators.pattern, ו-validators מותאמים אישית בין שדות (cross-field validators) – כולם מעריכים את הקומפוננטה המבוססת על CVA בדיוק כפי שהם היו מעריכים input טבעי. ה-form control רואה ערך ומצב. לא אכפת לו אם הערך הגיע מתיבת טקסט או מבורר חודשים (month-picker) שנבנה ידנית.

הניידות הזו היא הסיבה ש-CVA חשוב למערכות עיצוב (design systems) וספריות UI משותפות. צוות אחד בונה input חזק למספר טלפון או ווידג'ט להעלאת קבצים. הם מממשים את הממשק פעם אחת. כל שאר הצוותים בארגון משתמשים בו בתוך ה-Reactive Forms שלהם ללא כל חיבור (wiring) נוסף. הקומפוננטה מתנהגת בצורה צפויה, מבצעת ולידציה באופן אחיד, ומתנהגת בצורה עקבית בנטרול בכל מודול פיצ'ר.

השורה התחתונה

ControlValueAccessor הוא לא סתם עוד ממשק שצריך לשנן לשאלות בראיונות עבודה. הוא הגשר שמאפשר לקומפוננטות המותאמות אישית שלך להשתתף באקו-סיסטם של הטפסים של Angular כשווים לאלמנטים טבעיים של HTML. שליטה בו משמעותה הבנת השיחה המלאה בין הווידג'ט שלך לבין הטופס: קבלת ערכים, דיווח על שינויים, הודעה על touches, וכיבוד מצבי disabled. אם תבצע את ארבעת החלקים הללו נכון, תוכל לבנות form controls מורכבים וניתנים לשימוש חוזר שמרגישים "שקופים" למפתחים שמשת