Angular forms work beautifully with standard HTML. input, textarea, and select all slot into Reactive Forms without extra effort. The framework understands their events, their values, and their states.
But modern applications rarely get by on standard elements alone. You might need a star rating widget, a composite date selector, or a custom color picker. Drop one of these into a form group, and Angular treats it as dead HTML. patchValue does nothing. Validators ignore it. The form has no idea when a user interacts with the control, and form.disable() leaves the custom widget fully interactive.
This is the problem ControlValueAccessor exists to solve.
What ControlValueAccessor Actually Does
ControlValueAccessor is the contract that turns a custom component into a first-class form citizen. It acts as a translator between the Angular Forms API and your own UI. Once you implement it correctly, your component becomes indistinguishable from a native input from the form’s point of view. It can receive values, emit changes, report touches, and respect disabled states just like a built-in element.
The interface requires four specific methods. Each one handles a distinct direction of communication.
writeValue: Form to Component
writeValue(obj) is the inbound lane. Whenever the form model updates and needs to push a new value into your UI, Angular calls this method. If you invoke patchValue({ rating: 4 }) on a form group, that value of 4 arrives inside your component through writeValue. If you reset the form, writeValue receives the new initial value or null. Your job inside this method is to take that incoming data and map it onto your component’s internal state. If you are building a color picker, writeValue receives a hex string like #ff4400, and you must update your view to show that color as selected.
There is a practical wrinkle here. Angular can call writeValue before your view is fully initialized, especially inside dynamically rendered components, dialogs, or tabbed interfaces. If your component tries to touch the DOM or child components too early, you can hit runtime errors. A solid pattern is to store the value in a local property and apply it after the view initializes, or to guard against undefined child references. Never assume writeValue only fires when your template is stable.
registerOnChange: Component to Form
registerOnChange(fn) sets up the outbound lane. Angular hands you a callback function, and you must keep a reference to it. Every time the user changes the value inside your component, you call that function with the new value. In a star rating component, when the user clicks the third star, you invoke the stored callback with 3. That call flows back into the FormControl, updates the model, triggers any valueChanges subscriptions, and re-runs validators.
Skipping this step is the most common way to silently break a form. The widget might look alive. The user sees stars light up, colors shift, or dates populate. But the form model never updates. Validators continue to evaluate stale data. Submit handlers send old values. The component appears to work, yet the form is effectively blind. If your custom control accepts user input but the surrounding form never notices, this is almost always the culprit.
registerOnTouched: Reporting Interaction
Forms do not just track values. They track whether a user has interacted with a field. Angular uses the touched state to decide when it is appropriate to show validation errors. A required text input should not flash red the instant the page loads. It should wait until the user tabs away or clicks elsewhere.
Native inputs handle this automatically through blur events. Custom components do not. You must use registerOnTouched(fn) to report these interactions yourself. Angular gives you another callback; you call it when you decide the user has meaningfully engaged with the control.
The exact timing depends on your component. For a text-like custom input, you might call it on blur. For a star rating, the first click is probably the right moment. For a color picker that opens a popover, you might wait until the palette closes. The key is consistency. If you never call the touched callback, Angular keeps marking the control as pristine. Validation errors stay hidden even after the user has clearly finished editing. That leads to confusion and poor user experience.
setDisabledState:遵循表单指令
动态表单会根据业务逻辑不断启用或禁用字段。当你对 FormControl 调用 .disable() 时,Angular 需要你的自定义组件做出响应。setDisabledState(isDisabled) 接收一个布尔值。当其为 true 时,你应该锁定你的 UI。
这不仅仅意味着忽略点击。你应该禁用内部按钮、移除可聚焦状态,并应用视觉处理,例如降低不透明度或设置 pointer-events: none。如果你忽略了这个方法,你的组件将保持完全交互状态,而表单模型却坚持认为它已被禁用。这会产生难以追踪的 bug。用户可能会修改那些表单理应拒绝的值。保存按钮可能会基于无效状态被启用。表单组(form group)与 UI 之间会产生脱节。
一个构建良好的自定义控件会将 setDisabledState 视为首要需求,而非事后补救。
会浪费你调试时间的错误
几个反复出现的错误常会让初次接触该接口的开发者栽跟头。
忘记调用变更回调(change callback)。 你的组件更新了其内部状态,但表单对此一无所知。验证器(Validators)会停滞,父表单会提交陈旧的数据。务必在用户提交新值的那一刻,立即触发存储的 onChange 函数。
跳过 touched 回调。 如果没有它,Angular 永远不会将控件标记为 touched。与 touched 或 dirty 状态绑定的错误消息将无法显示。用户会盯着一个看起来没问题但无法提交的表单,却看不到任何错误提示。
忽视禁用状态。 一个视觉上处于启用状态但表单认为已被禁用的控件,会破坏信任边界。用户可以继续输入或点击,但模型会忽略他们。更糟糕的是,模型可能会在同步周期中偶尔覆盖他们的输入。
遗漏 NG_VALUE_ACCESSOR 提供者(provider)。 这是“隐形杀手”。如果你实现了这四个方法,但忘记将 NG_VALUE_ACCESSOR 添加到组件的 providers 数组中,Angular 就永远不会将你的组件注册为值访问器(value accessor)。代码可以编译,视图可以渲染,但没有任何绑定。没有错误消息,只有一个完全脱离表单之外的组件。务必将其包含在装饰器元数据(decorator metadata)中。
Signals、Validators 与现代 Angular
ControlValueAccessor 并非过时的 API。它能完美融入现代 Angular 开发。无论你是使用 Signals、普通属性还是 RxJS subjects 来管理内部状态,这四个方法始终是你与 forms 模块之间的公共契约。你在 writeValue 中接收值,修改你的 Signals 或状态,并通过 Angular 提供的回调进行发射(emit)。
标准验证器无需修改即可工作。Validators.required、Validators.min、Validators.pattern 以及自定义的跨字段验证器,都会像对待原生 input 一样评估你基于 CVA 构建的组件。form control 只关注值和状态。它并不关心该值是来自文本框还是一个手工制作的月份选择器(month-picker)。
正是这种可移植性,使得 CVA 对于设计系统和共享 UI 库至关重要。一个团队构建了一个健壮的电话号码输入框或文件上传组件。他们只需实现一次接口。组织内的其他每个团队都可以将其直接放入其 Reactive Forms 中,无需任何额外的连线工作。该组件在每个功能模块中都能表现出可预测的行为、统一的验证和一致的禁用状态。
核心总结
ControlValueAccessor 不仅仅是另一个为了面试而需要死记硬背的接口。它是连接自定义组件与 Angular 表单生态系统的桥梁,使其能像原生 HTML 元素一样平等参与其中。掌握它意味着理解你的组件与表单之间的完整对话:接收值、报告变更、宣告触碰(touches)以及遵循禁用状态。处理好这四个环节,你就能构建出复杂且可复用的表单控件,让使用它们的开发者感觉不到其存在(即实现无缝集成)。这就是专业 Angular 组件的标志。
