Angular forms and routing
covers reactive forms & FormArray · typed forms & getRawValue · custom, async & cross-field validators · ControlValueAccessor · lazy routes & functional guards · route params, resolvers, input binding
Building a reactive form
A reactive form is a tree of AbstractControl objects you own in the component class. Three shapes cover almost everything. A FormControl is one field with a value and a validity status. A FormGroup is a fixed, named set of controls (a form, or a nested section). A FormArray is a numerically indexed list you grow and shrink at runtime.
const form = new FormGroup({
name: new FormControl(""),
email: new FormControl(""),
phones: new FormArray([new FormControl("")]),
});
form.get("phones") as FormArray; // .push(...), .removeAt(i), .insert(i, ...)
FormBuilder (get it via the constructor, or the shorter inject(FormBuilder)) trims the boilerplate: fb.group({ name: [""], email: [""] }). Wire the group to the template with [formGroup]="form" and each field with formControlName="email".
Two ways to write values back into the model, and they behave differently. setValue replaces the whole group and throws if you leave out a key or add an unknown one, which catches typos. patchValue updates only the keys you pass and ignores the rest, which is what you want for a partial edit.
Why form.value can be missing fields
Reactive forms are strictly typed by default since Angular 14, so form.value and control.valueChanges carry real types instead of any. One consequence surprises people: a disabled control is left out of form.value entirely, and the type reflects that as a Partial.
const form = new FormGroup({
name: new FormControl("", { nonNullable: true }),
email: new FormControl("", { nonNullable: true }),
});
form.controls.email.disable();
form.value; // { name: string } email is gone
form.getRawValue(); // { name: string; email: string }
Wrong "I disabled the field for display, so it will still be in the submit payload." It will not. form.value skips every disabled control. Right if you need the disabled values in the payload, call getRawValue(), which includes them. And .reset() normally sets a control back to null; pass { nonNullable: true } when you create the control and reset returns it to its initial value instead, which keeps a string control from becoming string | null.
Custom and cross-field validators
A validator is a plain function: it receives an AbstractControl and returns null when the value is valid, or an errors object when it is not. Register sync validators as the second argument to a control, async ones in the options object.
export function forbiddenName(re: RegExp): ValidatorFn {
return (control) => (re.test(control.value) ? { forbiddenName: true } : null);
}
new FormControl("", [Validators.required, forbiddenName(/admin/i)]);
A rule that compares two fields (password and confirm, start date before end date) has to see both, so it goes on the FormGroup that holds them, not on either child. A group-level validator receives the group and reads its children through control.get(...).
const match: ValidatorFn = (group) =>
group.get("password")?.value === group.get("confirm")?.value
? null
: { mismatch: true };
new FormGroup({ password: new FormControl(""), confirm: new FormControl("") },
{ validators: match });
Wrong "Put the match check on the confirm control and read the password from there." A validator on one control only receives that control, so it cannot see its sibling reliably and misses a mismatch you create by editing the other field. Right attach cross-field rules to the common parent group, and display the error by reading form.hasError("mismatch").
Lazy routes and functional guards
Routes are a plain array you hand to provideRouter at bootstrap. Load a whole area only when the user reaches it: loadComponent pulls in one standalone component, loadChildren pulls in a routes file. Both use the standard dynamic import().
export const routes: Routes = [
{ path: "login", loadComponent: () => import("./login") }, // one component
{ path: "admin", loadChildren: () => import("./admin.routes") }, // a routes file
{ path: "**", redirectTo: "login" }, // wildcard last
];
A guard is now just a function. CanActivateFn gets the route and calls inject() to reach its services (the DI mechanics themselves live in the components-and-di module). Return true to allow, false to block, or a UrlTree to redirect.
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn() || router.parseUrl("/login"); // UrlTree redirects
};
Returning a UrlTree is the redirect: do not return false and then call router.navigate yourself, which races the router.
Reading route params without stale data
ActivatedRoute exposes the current params two ways, and picking wrong is a common bug. route.snapshot.paramMap.get("id") reads the value once. route.paramMap is an observable that emits every time the param changes.
The catch is component reuse. When the user navigates from /user/1 to /user/2, the router keeps the same component instance because only the parameter changed, so anything you read once in the constructor or ngOnInit stays frozen on the first value.
export class UserPage {
private route = inject(ActivatedRoute);
id = toSignal(this.route.paramMap.pipe(map((p) => p.get("id"))));
}
Wrong "I read the id in the constructor, so it updates when the URL does." Not on a reused component: the snapshot never re-runs. Right subscribe to route.paramMap, or bind the param straight to an input (covered in the senior notes) so it re-emits on each navigation.
Custom form controls with ControlValueAccessor
To make your own component (a star rating, a tag picker) work with formControlName like a native input, it implements ControlValueAccessor. The interface is four methods, and the whole thing is a two-way bridge between the forms model and your view.
writeValue(value) pushes a value from the model into your view. registerOnChange(fn) and registerOnTouched(fn) hand you two callbacks to keep: call the change one when the user picks a value, call the touched one when the control loses focus. setDisabledState(isDisabled) fires when the form disables the control. You register the component as a value accessor with a multi-provider.
@Component({
selector: "star-rating",
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: StarRating, multi: true }],
})
export class StarRating implements ControlValueAccessor {
value = 0;
private onChange: (v: number) => void = () => {};
private onTouched: () => void = () => {};
writeValue(v: number) { this.value = v ?? 0; } // model -> view
registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(disabled: boolean) { /* toggle the UI */ }
pick(star: number) { // view -> model
this.value = star;
this.onChange(star);
this.onTouched();
}
}
The two callbacks are where most custom controls break. Forget to call onTouched and the control never goes touched, so touched-gated error messages never appear and a blur-based async validator never runs. Ignore setDisabledState and form.disable() visibly does nothing to your widget while the model thinks it is disabled. And never call onChange from inside writeValue: writeValue is the model talking to you, so echoing it back can loop.
Async validators, pending, and updateOn
An async validator returns an Observable (or Promise) of ValidationErrors | null, and the observable must complete. While it is in flight the control's status is PENDING, which is the state you gate a submit button on.
export function uniqueEmail(api: UserApi): AsyncValidatorFn {
return (control) => api.emailTaken(control.value).pipe(
map((taken) => (taken ? { taken: true } : null)),
);
}
new FormControl("", { asyncValidators: [uniqueEmail(api)], updateOn: "blur" });
The default updateOn is "change", which re-runs validators on every keystroke. For a validator that hits the network that means a request per character, so set updateOn: "blur" (or "submit") to validate once the user leaves the field. If you do want live checking, the debounce-and-switchMap pipeline over valueChanges belongs to the rxjs-patterns module; the point here is that the control exposes PENDING so the UI can show a spinner and block submit while it resolves.
Gating lazy routes: canMatch vs canActivate
Both guard a route, but they run at different moments, and the difference decides whether a rejected user still pays for the bundle.
canMatch runs while the router is still matching URLs. Return false and the route is treated as not matching, so the router tries other routes and, crucially, loadChildren or loadComponent never fires. canActivate runs after the route has matched and its bundle has loaded. So an auth check on a lazy admin area belongs in canMatch: put it in canActivate and a logged-out user downloads the admin chunk before the guard turns them away.
The unsaved-changes guard is a CanDeactivateFn, which receives the component instance you are leaving.
export const unsavedGuard: CanDeactivateFn<EditForm> = (component) =>
component.form.pristine || confirm("Discard unsaved changes?");
Like every guard, prefer returning a UrlTree or RedirectCommand for a redirect over returning false and navigating imperatively.
Resolvers and blocking navigation
A ResolveFn fetches data before the route activates. You register it under a key, and the resolved value lands in route.data (or on an input, below).
export const orgResolver: ResolveFn<Org> = (route) => {
const api = inject(OrgApi);
const router = inject(Router);
return api.getOrg(route.paramMap.get("orgId")!).pipe(
catchError(() => of(new RedirectCommand(router.parseUrl("/admin")))),
);
};
The property that makes resolvers useful is also their trap: navigation is blocked until every resolver on the route completes. A slow fetch leaves the user on the old page with no feedback, and a resolver with no error handling leaves navigation hanging when the request fails. Handle the failure in the resolver: return EMPTY to cancel the navigation, or a RedirectCommand to send the user elsewhere.
Wrong "A resolver is the right way to load a page's data because the page always has it ready." Sometimes. For anything slow, blocking the route with no spinner is worse than rendering the shell and loading inside the component with its own loading and error state. Right reach for a resolver when the data is fast and the page is meaningless without it; otherwise let the component load its own data.
Route params under component reuse and input binding
The router reuses a component across a param-only change, so a snapshot read goes stale (covered in the middle notes). The modern fix is withComponentInputBinding(), which binds route state straight to component inputs.
// provideRouter(routes, withComponentInputBinding())
export class Dashboard {
orgId = input.required<string>(); // bound from the :orgId path param
org = input.required<Org>(); // bound from the org resolver
}
It binds four sources by matching an input's name to a key: query params, path and matrix params, static route data, and resolver data. When the same name exists in more than one, the precedence runs query params (lowest), then path and matrix params, then static data, then resolver data (highest). One caveat: if a source loses a key, the matching input is set back to undefined rather than keeping its old value, so an input can go from a value to undefined across a navigation. When you want a signal instead of an input, toSignal(route.paramMap) gives you the same reactivity without the binding feature.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
gapmap pro
You've read the Angular forms and routing notes. An interviewer will ask you to prove them.
Know you're ready. Don't hope.
The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:
Mock interviews on your topics
An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.
Voice test interviews
The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.
A plan to your date
Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.
A mentor inside every lesson
Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.
Pro $20/mo · Pro+ $50/mo · every study note on the site stays free