From the field notes
Angular date, time and datetime controls with NgControl
On a project I worked on, our forms were generated dynamically. We had form groups, controls, and components deciding which fields to render. Date, time, and datetime fields needed a reusable home in that hierarchy.
The part I liked about the solution was how little the consuming template needed to know. A field could use formControlName, just like a regular input. We didn’t have to pass the FormControl through every component between the form and the field.
For this example, we’ll use Angular Material’s Datepicker for dates, Timepicker for times, and both pickers together for a datetime field. The datetime component has two inputs, but exposes one value to the parent form.
Try the working example and explore its code.
On CodePen, index.ts contains all the Angular components and their inline templates to keep the example within its three-file limit. Below, we’ll use the filenames you’d give them in an application. Comments in the Pen mark where each virtual file starts.
Let the form own the controls
The parent creates the reactive form. Here’s a small group for an appointment:
readonly form = new FormGroup({
appointment: new FormGroup({
date: new FormControl<Date | null>(null, Validators.required),
time: new FormControl<Date | null>(null, Validators.required),
datetime: new FormControl<Date | null>(null, Validators.required),
}),
});The field renderer receives a description of the fields to display. It doesn’t receive the controls themselves:
<form [formGroup]="form">
<div formGroupName="appointment">
<app-dynamic-fields [fields]="fields" />
</div>
</form>Inside that renderer, a date field becomes:
<app-date-control
[formControlName]="field.name"
[label]="field.label"
/>There are two connections to make here: finding the right group across the component boundary, and connecting our custom component to its control.
Carry the nearest group into the child view
ControlContainer represents a container of controls. Both the root form and a nested FormGroupName participate in this hierarchy.1
The dynamic field renderer forwards its nearest ancestor container into its own view:
viewProviders: [{
provide: ControlContainer,
useFactory: () => inject(ControlContainer, { skipSelf: true }),
}],That lets the renderer’s formControlName directives find the appointment group. Forwarding the nearest container matters: always supplying the root FormGroupDirective would lose the nested group’s context.
Keep formGroupName on an ancestor of the forwarding component, as in the template above. skipSelf deliberately skips providers on the component’s own element.
Find the field through NgControl
NgControl is an abstract base class for directives such as FormControlName. We inject that token to get the directive Angular attached to our custom component’s host.2
The component implements ControlValueAccessor to communicate with that directive. Angular writes values through writeValue, registers callbacks for changes and touches, and calls setDisabledState when the control is disabled.3
Our components also provide a validator so that errors from Material’s inner inputs reach the outer control. Those providers reference the component, so resolving NgControl in the constructor would create a dependency cycle. Resolve it after construction instead:
private readonly injector = inject(Injector);
ngControl: NgControl | null = null;
ngOnInit(): void {
this.ngControl = this.injector.get(NgControl, null, { self: true });
}self: true keeps the lookup on this field’s host. From there we can inspect its control’s validators and interaction state. An ErrorStateMatcher makes the Material fields display errors when that outer control is invalid and touched, or when the form has been submitted.
The picker inputs have their own internal controls. They handle editing and parsing; the parent still sees one public control per component. Model updates use emitEvent: false when writing into the inner controls, so writing a value doesn’t immediately echo it back as a user change.
Combine the date and time
The datetime component keeps the two picker values separately. Once both are present, it copies the selected date and applies the selected hours and minutes:
const combined = new Date(date);
combined.setHours(time.getHours(), time.getMinutes(), 0, 0);Changing the calendar selection preserves the chosen time. Changing the time preserves the chosen date. With only one half filled in, the component reports an incomplete datetime rather than quietly choosing the missing half for you.
Use provideNativeDateAdapter() to back both Material pickers with JavaScript Date objects.4 The example uses Angular and Angular Material 22.2.3.
There’s one detail to keep in mind when looking at the form’s JSON output: Date serializes to UTC, while the pickers display local values. A date-only field and a time-only field aren’t complete timestamps. Serialize the relevant local parts when that’s what your API expects. A datetime that needs an explicitly chosen timezone needs that timezone represented separately, too.
Footnotes
-
Angular’s ControlContainer API (opens in a new tab). ↩
-
Angular’s NgControl API (opens in a new tab). ↩
-
Angular’s ControlValueAccessor API (opens in a new tab). ↩
-
Angular Material’s Datepicker (opens in a new tab) and Timepicker (opens in a new tab) documentation. ↩