Angular forms hoạt động rất tuyệt vời với HTML tiêu chuẩn. Các thẻ input, textarea, và select đều có thể tích hợp vào Reactive Forms mà không cần thêm nỗ lực nào. Framework này hiểu rõ các sự kiện, giá trị và trạng thái của chúng.

Nhưng các ứng dụng hiện đại hiếm khi chỉ dựa vào các phần tử tiêu chuẩn. Bạn có thể cần một widget đánh giá sao (star rating), một bộ chọn ngày phức hợp, hoặc một bộ chọn màu tùy chỉnh. Nếu đưa một trong những thành phần này vào một form group, Angular sẽ coi nó như HTML "chết". patchValue sẽ không có tác dụng gì. Các bộ kiểm tra (Validators) sẽ bỏ qua nó. Form sẽ không biết khi nào người dùng tương tác với control đó, và form.disable() cũng không thể vô hiệu hóa widget tùy chỉnh này.

Đây chính là vấn đề mà ControlValueAccessor ra đời để giải quyết.

Bản chất của ControlValueAccessor

ControlValueAccessor là một bản hợp đồng (contract) giúp biến một component tùy chỉnh thành một "công dân" chính thức của form. Nó đóng vai trò như một bộ thông dịch giữa Angular Forms API và giao diện người dùng (UI) của riêng bạn. Một khi bạn triển khai nó chính xác, component của bạn sẽ trở nên không thể phân biệt được với một input gốc dưới góc nhìn của form. Nó có thể nhận giá trị, phát ra các thay đổi, báo cáo trạng thái đã chạm (touched) và tuân thủ các trạng thái vô hiệu hóa (disabled) giống hệt như một phần tử có sẵn.

Interface này yêu cầu bốn phương thức cụ thể. Mỗi phương thức xử lý một hướng giao tiếp riêng biệt.

writeValue: Từ Form đến Component

writeValue(obj) là luồng dữ liệu đi vào (inbound). Bất cứ khi nào model của form cập nhật và cần đẩy một giá trị mới vào UI của bạn, Angular sẽ gọi phương thức này. Nếu bạn gọi patchValue({ rating: 4 }) trên một form group, giá trị 4 đó sẽ được truyền vào bên trong component của bạn thông qua writeValue. Nếu bạn reset form, writeValue sẽ nhận giá trị khởi tạo mới hoặc null. Nhiệm vụ của bạn trong phương thức này là lấy dữ liệu đầu vào đó và ánh xạ (map) nó vào trạng thái nội bộ (internal state) của component. Nếu bạn đang xây dựng một bộ chọn màu, writeValue sẽ nhận một chuỗi hex như #ff4400, và bạn phải cập nhật view để hiển thị màu đó là màu đã được chọn.

Có một vấn đề thực tế ở đây. Angular có thể gọi writeValue trước khi view của bạn được khởi tạo hoàn toàn, đặc biệt là bên trong các component được render động, các hộp thoại (dialogs), hoặc các giao diện dạng tab. Nếu component của bạn cố gắng tác động vào DOM hoặc các component con quá sớm, bạn có thể gặp lỗi runtime. Một mô hình (pattern) tốt là lưu trữ giá trị vào một thuộc tính cục bộ và áp dụng nó sau khi view đã khởi tạo, hoặc kiểm tra để tránh các tham chiếu con bị undefined. Đừng bao giờ mặc định rằng writeValue chỉ chạy khi template của bạn đã ổn định.

registerOnChange: Từ Component đến Form

registerOnChange(fn) thiết lập luồng dữ liệu đi ra (outbound). Angular sẽ đưa cho bạn một hàm callback, và bạn phải giữ một tham chiếu đến nó. Mỗi khi người dùng thay đổi giá trị bên trong component của bạn, bạn gọi hàm đó với giá trị mới. Trong một component đánh giá sao, khi người dùng nhấp vào ngôi sao thứ ba, bạn sẽ gọi hàm callback đã lưu với giá trị 3. Lời gọi đó sẽ truyền ngược lại vào FormControl, cập nhật model, kích hoạt bất kỳ subscription valueChanges nào và chạy lại các validators.

Bỏ qua bước này là cách phổ biến nhất khiến form bị lỗi một cách âm thầm. Widget có vẻ như vẫn hoạt động bình thường. Người dùng thấy các ngôi sao sáng lên, màu sắc thay đổi, hoặc ngày tháng được điền vào. Nhưng model của form thì không bao giờ cập nhật. Các validators tiếp tục đánh giá dữ liệu cũ. Các hàm xử lý submit sẽ gửi đi các giá trị cũ. Component có vẻ như đang hoạt động, nhưng thực tế là form đang bị "mù". Nếu control tùy chỉnh của bạn chấp nhận đầu vào của người dùng nhưng form bao quanh không hề hay biết, thì đây gần như luôn là nguyên nhân.

registerOnTouched: Báo cáo tương tác

Form không chỉ theo dõi các giá trị. Chúng còn theo dõi xem người dùng đã tương tác với một trường dữ liệu hay chưa. Angular sử dụng trạng thái touched để quyết định khi nào thì hiển thị các lỗi validation là phù hợp. Một input văn bản bắt buộc (required) không nên hiện màu đỏ ngay lập tức khi trang vừa tải xong. Nó nên đợi cho đến khi người dùng chuyển sang tab khác hoặc nhấp vào nơi khác.

Các input gốc xử lý việc này tự động thông qua các sự kiện blur. Các component tùy chỉnh thì không. Bạn phải sử dụng registerOnTouched(fn) để tự báo cáo các tương tác này. Angular cung cấp cho bạn một callback khác; bạn sẽ gọi nó khi bạn xác định rằng người dùng đã thực sự tương tác với control.

Thời điểm chính xác tùy thuộc vào component của bạn. Đối với một input tùy chỉnh dạng văn bản, bạn có thể gọi nó khi blur. Đối với đánh giá sao, cú nhấp chuột đầu tiên có lẽ là thời điểm thích hợp. Đối với một bộ chọn màu mở ra một popover, bạn có thể đợi cho đến khi bảng màu đóng lại. Chìa khóa là sự nhất quán. Nếu bạn không bao giờ gọi callback touched, Angular sẽ tiếp tục đánh dấu control đó là pristine. Các lỗi validation sẽ vẫn bị ẩn ngay cả khi người dùng đã hoàn tất việc chỉnh sửa. Điều đó dẫn đến sự bối rối và trải nghiệm người dùng kém.

setDisabledState: Tuân thủ các lệnh của Form

Các form động liên tục bật/tắt (enable/disable) các trường dựa trên logic nghiệp vụ. Khi bạn gọi .disable() trên một FormControl, Angular cần component tùy chỉnh của bạn phản hồi lại. setDisabledState(isDisabled) nhận vào một giá trị boolean. Khi giá trị này là true, bạn nên khóa giao diện người dùng (UI) của mình lại.

Điều này không chỉ đơn thuần là bỏ qua các lượt click. Bạn nên vô hiệu hóa các nút nội bộ, loại bỏ các trạng thái có thể nhận tiêu điểm (focusable), và áp dụng các hiệu ứng hình ảnh như giảm độ mờ (opacity) hoặc pointer-events: none. Nếu bạn bỏ qua phương thức này, component của bạn vẫn tương tác đầy đủ trong khi mô hình form (form model) khẳng định rằng nó đã bị vô hiệu hóa. Điều đó tạo ra những lỗi rất khó truy vết. Người dùng có thể thay đổi các giá trị mà đáng lẽ form phải từ chối. Các nút Lưu có thể được kích hoạt dựa trên các trạng thái không hợp lệ. Form group và UI sẽ dần mất đi sự đồng bộ.

Một custom control được xây dựng tốt sẽ coi setDisabledState là một yêu cầu tiên quyết, chứ không phải là một phần bổ sung sau cùng.

Những sai lầm gây lãng phí thời gian debug

Có một vài sai lầm lặp đi lặp lại thường khiến các lập trình viên mới làm quen với interface này gặp khó khăn.

Quên gọi callback thay đổi (change callback). Component của bạn cập nhật trạng thái nội bộ, nhưng form không hề hay biết. Các bộ kiểm tra (Validators) bị đình trệ, và các form cha sẽ gửi đi dữ liệu cũ (stale data). Hãy luôn gọi hàm onChange đã lưu ngay khi người dùng xác nhận một giá trị mới.

Bỏ qua callback touched. Nếu không có nó, Angular sẽ không bao giờ đánh dấu control là đã được chạm vào (touched). Các thông báo lỗi gắn liền với trạng thái touched hoặc dirty sẽ không hiển thị. Người dùng sẽ nhìn chằm chằm vào một form trông có vẻ đúng nhưng không thể submit, mà không có bất kỳ dấu hiệu nào cho thấy lỗi nằm ở đâu.

Bỏ qua trạng thái disabled. Một control trông có vẻ đang hoạt động nhưng form lại nghĩ là đã bị vô hiệu hóa sẽ tạo ra một ranh giới tin cậy bị phá vỡ. Người dùng có thể tiếp tục nhập hoặc click, nhưng mô hình (model) sẽ lờ họ đi. Hoặc tệ hơn, mô hình có thể ghi đè dữ liệu nhập của họ một cách ngẫu nhiên trong các chu kỳ đồng bộ.

Thiếu provider NG_VALUE_ACCESSOR. Đây là "kẻ sát nhân thầm lặng". Nếu bạn triển khai cả bốn phương thức nhưng quên thêm NG_VALUE_ACCESSOR vào mảng providers của component, Angular sẽ không bao giờ đăng ký component của bạn như một value accessor. Code vẫn biên dịch được. View vẫn hiển thị. Nhưng không có gì được liên kết (bind). Không có thông báo lỗi nào cả, chỉ là một component hoàn toàn nằm ngoài phạm vi của form. Hãy luôn bao gồm nó trong decorator metadata.

Signals, Validators và Angular hiện đại

ControlValueAccessor không phải là một bề mặt API lỗi thời. Nó khớp hoàn hảo với quy trình phát triển Angular hiện đại. Dù bạn quản lý trạng thái nội bộ bằng Signals, các thuộc tính thông thường hay RxJS subjects, bốn phương thức này vẫn là hợp đồng công khai (public contract) của bạn với module forms. Bạn tiếp nhận giá trị trong writeValue, thay đổi Signals hoặc trạng thái của mình, và phát ra (emit) thông qua các callback mà Angular cung cấp.

Các validator tiêu chuẩn hoạt động mà không cần chỉnh sửa. Validators.required, Validators.min, Validators.pattern, và các validator tùy chỉnh giữa các trường (cross-field) đều đánh giá component dựa trên CVA của bạn chính xác như cách chúng làm với một input thuần túy. Form control chỉ nhìn thấy một giá trị và một trạng thái. Nó không quan tâm giá trị đó đến từ một ô văn bản hay một bộ chọn tháng (month-picker) được xây dựng thủ công.

Khả năng di động đó là lý do tại sao CVA lại quan trọng đối với các design system và thư viện UI dùng chung. Một nhóm xây dựng một input số điện thoại mạnh mẽ hoặc một widget tải tệp lên. Họ triển khai interface đó một lần. Mọi nhóm khác trong tổ chức chỉ cần đưa nó vào Reactive Forms của họ mà không cần thêm bất kỳ kết nối (wiring) nào. Component sẽ hoạt động một cách có thể dự đoán được, kiểm tra tính hợp lệ đồng nhất và vô hiệu hóa nhất quán trên mọi module tính năng.

Bài học cốt lõi

ControlValueAccessor không chỉ là một interface khác để học thuộc lòng cho các câu hỏi phỏng vấn. Nó là chiếc cầu nối cho phép các component tùy chỉnh của bạn tham gia vào hệ sinh thái form của Angular một cách bình đẳng như các phần tử HTML thuần túy. Làm chủ được nó nghĩa là hiểu được toàn bộ cuộc hội thoại giữa widget của bạn và form: tiếp nhận giá trị, báo cáo thay đổi, thông báo các lần chạm (touches) và tuân thủ các trạng thái vô hiệu hóa. Khi nắm vững bốn thành phần này, bạn có thể xây dựng các form control phức tạp, có thể tái sử dụng và mang lại cảm giác "vô hình" cho các lập trình viên sử dụng chúng. Đó chính là dấu ấn của một Angular component chuyên nghiệp.