Angular provides two approaches to handling forms: template-driven forms for simple cases and reactive forms for complex scenarios. Both provide validation, error handling, and form state management.Forms are often the most complex part of any frontend application — they involve user input, validation, error messages, async checks (like “is this username taken?”), conditional fields, and state management. Getting forms right is the difference between an app that feels polished and one that frustrates users.
Which should you use? For a contact form or a simple login page, template-driven forms are faster to build. For anything with dynamic fields, multi-step wizards, complex validation logic, or forms you need to unit test thoroughly, use reactive forms. In production codebases, reactive forms are overwhelmingly more common because most real-world forms are more complex than tutorials suggest.
┌─────────────────────────────────────────────────────────────────────────┐│ Template-Driven vs Reactive Forms │├─────────────────────────────┬───────────────────────────────────────────┤│ Template-Driven │ Reactive Forms │├─────────────────────────────┼───────────────────────────────────────────┤│ • Logic in template │ • Logic in component class ││ • Uses ngModel directive │ • Uses FormControl, FormGroup ││ • Two-way data binding │ • Explicit data flow ││ • Less boilerplate │ • More testable ││ • Good for simple forms │ • Good for complex forms ││ • Async validation tricky │ • Easy async validation ││ │ • Dynamic form generation ││ │ • Better type safety │└─────────────────────────────┴───────────────────────────────────────────┘
import { FormControl, FormGroup, FormArray, Validators } from '@angular/forms';// FormControl - single inputconst nameControl = new FormControl('', [Validators.required]);console.log(nameControl.value); // ''console.log(nameControl.valid); // falseconsole.log(nameControl.errors); // { required: true }// FormGroup - group of controlsconst userForm = new FormGroup({ name: new FormControl('', Validators.required), email: new FormControl('', [Validators.required, Validators.email]), address: new FormGroup({ // Nested group street: new FormControl(''), city: new FormControl(''), zip: new FormControl('') })});// Access nested controlsuserForm.get('address.city')?.setValue('New York');// FormArray - dynamic array of controlsconst skills = new FormArray([ new FormControl('TypeScript'), new FormControl('Angular')]);// Add/remove items dynamicallyskills.push(new FormControl('RxJS'));skills.removeAt(0);
Angular 14+ provides strongly typed reactive forms. Before this feature, form.value returned any, which meant typos in field names would not be caught until runtime. With typed forms, the compiler catches mistakes at build time — if you rename a field in your interface but forget to update the form, TypeScript tells you immediately.
Q: You are building a multi-step checkout form with address validation, payment processing, and dynamic fields based on the selected country. Walk me through your form architecture decisions.
Strong Answer: I would use reactive forms with FormBuilder for this, not template-driven. The reasons: multi-step wizards need programmatic control over which step is valid, dynamic fields need runtime FormGroup manipulation, and payment processing needs async validators that are easier with reactive forms.My architecture: one top-level FormGroup with nested FormGroups for each step — personal, shipping, payment. Each step component receives its FormGroup as an input and renders the fields. The parent wizard component owns the entire form and controls navigation. This separation means step components are reusable and testable in isolation.For dynamic country-based fields: when the user selects a country, I dynamically add or remove FormControls. US addresses need state and zip. UK addresses need postcode and county. I listen to the country control’s valueChanges observable and call addControl/removeControl on the address FormGroup. I also swap validators — US zip needs a 5-digit pattern, UK postcode needs a different pattern.For async validation on the address, I use an async validator that calls an address verification API with debounce (to avoid calling on every keystroke). The form shows a “validating…” state (ng-pending class) while the API call is in flight.For step navigation: each step checks its own FormGroup validity before allowing “Next.” The final step can validate the entire form at once. I persist form state to localStorage via an effect, so the user can resume if they accidentally close the tab.Follow-up: How do you handle the password field validation where confirm-password must match password?
Answer: I use a cross-field validator at the FormGroup level, not on individual controls. The validator function receives the group, reads both password and confirmPassword values, and returns an error object if they do not match. This is important because the error lives on the group, not on either control — so the template needs to check formGroup.errors?.passwordMismatch, not control.errors. A common mistake is putting the validator on confirmPassword, which does not have access to the password value.
Q: Explain the difference between NonNullableFormBuilder and the regular FormBuilder. Why does it matter for type safety?
Strong Answer: Regular FormBuilder creates controls where the value type includes null and undefined. When you call form.reset(), all controls are set to null (not their initial values). So form.controls.name.value has type string | null, and you need null checks everywhere.NonNullableFormBuilder changes two things. First, reset() restores controls to their initial values instead of null. Second, the TypeScript types reflect this — form.controls.name.value is string, not string | null. This eliminates an entire category of null-check boilerplate.In practice, this matters for type safety downstream. If you pass form.getRawValue() to an API service that expects a User object (no null fields), the regular FormBuilder version will not type-check without explicit casting or null assertions. The NonNullableFormBuilder version matches the API type naturally.The gotcha: NonNullableFormBuilder does not prevent individual controls from being explicitly set to null via setValue(null). It only affects the reset behavior and the inferred types. If your form legitimately needs nullable fields (like an optional date field), you still need to declare those as FormControl<string | null>.Follow-up: How do strongly typed forms interact with dynamic FormArrays?
Answer: FormArrays with typed forms require you to specify the type of controls they contain. this.fb.array<FormGroup<ItemForm>>([]) tells TypeScript that each element is a FormGroup matching the ItemForm interface. When you push or access elements, TypeScript knows the shape. The challenge is that dynamically created controls need to match the declared type exactly. If your ItemForm has required fields, you must initialize them when creating new form groups.
Q: How would you implement a custom form control (ControlValueAccessor) and why would you need one?
Strong Answer: ControlValueAccessor is the bridge between Angular’s forms API and custom UI components. Once implemented, your component works with formControlName, ngModel, validation, disabled state — all the form features. The classic use cases: star rating widgets, rich text editors, date range pickers, tag inputs, or any UI element that does not map to a native HTML input.You implement four methods: writeValue (called when the form sets the control’s value), registerOnChange (gives you a callback to call when the user changes the value), registerOnTouched (callback for when the control is blurred), and optionally setDisabledState. You also register the component as a NG_VALUE_ACCESSOR provider using forwardRef.The key insight that separates a good implementation from a fragile one: writeValue can be called at any time, including before ngOnInit. So do not assume the component is fully initialized when writeValue runs. Also, call the onChange callback only on user interaction, not in writeValue — otherwise you create infinite loops where the form sets a value, which triggers onChange, which updates the form, which calls writeValue again.I always test CVA components by using them inside a reactive form in the test, setting values programmatically, checking that the form value updates when the user interacts, and verifying that disabled state propagates correctly.Follow-up: Can you use signals inside a ControlValueAccessor implementation?
Answer: Yes, and it works well. You can store the internal value as a signal, use it in the template with signal reads, and call the registered onChange callback in the methods that respond to user interaction. The writeValue method calls this.value.set(newValue). This gives you reactive templates while maintaining full form integration. Just be careful not to trigger onChange from an effect that reads the value signal, as that creates a feedback loop.