Skip to content

Creating a Form Modal Component

In this lesson, we are going to create another dumb/presentational component and it is going to be one that is also shared with multiple features. For the home feature that we are currently working on we need the ability to display a form inside of the modal we are launching to allow the user to create a new checklist.

I feel kind of bad because I told you that it would get easier after our unusually difficult first component. That is true, but the form-modal is probably the second most difficult feature in the application. So again, we are going to touch on some somewhat advanced concepts here. Don’t feel too worried if things aren’t making complete sense.

We could just create a dumb component specifically for the home feature, but we are also going to need to do the exact same thing when we get to adding items to individual checklists in the checklist feature we will create later — we will again need to display a form inside of a modal. We might decide to just manually hard code forms for each of these features rather than having a single shared form component, but since our forms are going to be so simple (we basically just need to accept a single text input) it will be relatively easy to create a single component that can be shared with both features.

Create the Form Modal Component

Now we will create our dumb/presentational form component.

import { KeyValuePipe } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-form-modal',
template: `
<header>
<h2>{{ title() }}</h2>
<button (click)="close.emit()">close</button>
</header>
<section>
<form [formGroup]="formGroup()" (ngSubmit)="save.emit(); close.emit()">
@for (control of formGroup().controls | keyvalue; track control.key){
<div>
<label [for]="control.key">{{ control.key }}</label>
<input
[id]="control.key"
type="text"
[formControlName]="control.key"
/>
</div>
}
<button type="submit">Save</button>
</form>
</section>
`,
imports: [ReactiveFormsModule, KeyValuePipe],
})
export class FormModalComponent {
formGroup = input.required<FormGroup>();
title = input.required<string>();
save = output();
close = output();
}

This is the component in its entirety. It is a somewhat complex component, but also reasonably within the realms of the concepts we have been learning so far.

The only thing we haven’t actually seen yet here is the keyvalue pipe which we also add to the imports array through KeyValuePipe. The idea here is that this component will be given a FormGroup which contains form controls (e.g. we might have a username form control). The keyvalue pipe will allow us to access the key and value in these control objects. The idea is that we want to use the key, which is actually the name of the form control, and assign that as the formControlName for the input. In this way, the specific inputs we are dynamically rendering out will be correctly associated with their corresponding form control — that means updating the input field will update the form control’s value.

That is the most complex part here. We will talk through the rest in just a moment, but this is a good opportunity to just take a look at the code and see if you can understand generally what is happening.

Again, don’t worry if it isn’t all making sense. There is nothing you need to do right now, just see what you can figure out about the code before moving on.

There is a bit going on here, so let’s talk through what is going on. Let’s start with the class:

export class FormModalComponent {
formGroup = input.required<FormGroup>();
title = input.required<string>();
save = output();
close = output();
}

Remember that this is a dumb component, so generally it is not going to inject any dependencies and it doesn’t know about anything that is happening in the broader application. It just gets its inputs, and sends outputs to communicate with whatever parent component is using it (the dumb child component doesn’t even know what component is using it).

In this case, we have two inputs. We want to be able to configure the title to be displayed in the template, and we also allow the parent component to supply a FormGroup as an input. This is what will allow the parent component to configure what form fields to display. We will render out an input in the template for each control defined in the FormGroup (using the technique we talked about above).

We also have a save output that is used to indicate to the parent component when the save button has been clicked, and another output that is used to indicate when the close button has been clicked. Let’s take a closer look at the template now:

<header>
<h2>{{ title() }}</h2>
<button (click)="close.emit()">close</button>
</header>
<section>
<form [formGroup]="formGroup()" (ngSubmit)="save.emit(); close.emit()">
@for (control of formGroup().controls | keyvalue; track control.key){
<div>
<label [for]="control.key">{{ control.key }}</label>
<input
[id]="control.key"
type="text"
[formControlName]="control.key"
/>
</div>
}
<button type="submit">Save</button>
</form>
</section>

Angular hooks into the functionality of the standard HTML <form> element, which we can activate by binding our FormGroup to it using the formGroup directive (thanks to the ReactiveFormsModule import):

<form [formGroup]="formGroup()" (ngSubmit)="save.emit(); close.emit()">

Remember how a @Directive works by supplying a selector that determines what it attaches to? This is exactly how Angular makes these forms work — it’s just a directive that has a selector of [formGroup] (i.e. it will attach to anything that has the formGroup attribute).

We also bind to the ngSubmit event which is triggered when the form is submitted. When this happens, we want to trigger both our save and emit events. Something to notice here is that we do not do this:

<form [formGroup]="formGroup()" (ngSubmit)="handleSubmit()">

We could create a separate method, and then in that method run the code we need. But, in general, we will generally try to avoid writing callback methods like this if we can. Triggering actions directly in the template, and keeping things neat, is going to require us to use a more declarative design.

Whilst there isn’t anything explicitly wrong about using a handleSubmit() method here that triggers the same code — if you have a function like this it is much easier and more tempting to sneak imperative code into it, e.g:

handleSubmit(){
this.save.emit();
this.close.emit();
// trigger some other thing here
}

The author of StateAdapt, Mike Pearson, describes callback functions like this as:

The curly braces of functions that don’t return anything are like open arms inviting imperative code.

I think this is a fantastic thing to keep in mind, and it is why you will find very few functions like this in our components.

The trickiest thing we are doing here, and this is something that is reasonably advanced for an introductory application, is this:

@for (control of formGroup().controls | keyvalue; track control.key){
<div>
<label [for]="control.key">{{ control.key }}</label>
<input
[id]="control.key"
type="text"
[formControlName]="control.key"
/>
</div>
}

We have already discussed this a little, but it is worth going over some more.

This is how we render our dynamically created form. We loop through each of the controls inside of the FormGroup and render an input for each of them. When we create a FormGroup we will do something like this:

checklistForm = this.fb.group({
title: ['', Validators.required],
});

Notice that in the form above (we haven’t actually got to creating this yet) we have a field with a key of title. When we render our form in the template we need to bind the formControlName of the input we want to tie to that particular field using that key of title:

<input
[id]="control.key"
type="text"
[formControlName]="control.key"
/>

This is why we use the keyvalue pipe on formGroup.controls:

@for (control of formGroup.controls | keyvalue; track control.key){

Our form controls are structured something like this:

{
title: 'test',
someOtherValue: 'hello'
}

So, what we really need are those title and someOtherValue keys to use as our formControlName. The keyvalue pipe will convert that object into the following array:

[
{key: 'title', value: 'test'},
{key: 'someOtherValue', value: 'hello'}
]

Which we can easily loop over in our @for and grab the key values. This means we will get an <input> rendered out for each of the controls we define in the FormGroup and each input will be bound to the appropriate control in the FormGroup such that when the user updates the value, the value in the associated control in the FormGroup will update as well.

Create the FormGroup

We have the ability to pass a FormGroup and title into our modal now, so let’s make use of it!

export default class HomeComponent {
formBuilder = inject(FormBuilder);
checklistBeingEdited = signal<Partial<Checklist> | null>(null);
checklistForm = this.formBuilder.nonNullable.group({
title: [''],
});
}

To create our form, we are injecting the FormBuilder which just makes it easier to create a FormGroup than manually instantiating new FormGroup and FormControl objects.

<app-modal [isOpen]="!!checklistBeingEdited()">
<ng-template>
<app-form-modal
[title]="
checklistBeingEdited()?.title
? checklistBeingEdited()!.title!
: 'Add Checklist'
"
[formGroup]="checklistForm"
(close)="checklistBeingEdited.set(null)"
/>
</ng-template>
</app-modal>

NOTE: Remember that you will need to add our standalone FormModalComponent as an import in the imports array of the HomeComponent.

Now we can actually use our modal! It is ugly now because it still just pops in at the bottom of the template, but it works!

We can click Add Checklist and the modal will be displayed with a title of Add Checklist. Later when we are using this for editing it will display the title of the checklist being edited. Note our usage of the safe navigation operator ? and non-null assertion ! for the title.

checklistBeingEdited()?.title

This checks if checklistBeingEdited() is not null and that it has a title property. If it does, we go to this line:

checklistBeingEdited()!.title!

This uses the non-null assertion operator to tell TypeScript that checklistBeingEdited() is not null and also that title is not null — we know this because of the check we just did, but TypeScript does not.

The end result is basically: Add Checklist if the signal value does not have a title, otherwise it will use the title of the checklist.

We are also handling our close:

(close)="checklistBeingEdited.set(null)"

When close emits we set the checklistBeingEdited signal to null to close the modal.

The only thing we are not handling here is the save — if we click “Save” then the modal will still close, but nothing actually happens with the data we are trying to save.

Creating a side effect

The last thing we are going to do in this lesson is handle resetting our form. Try this:

  • Click Add Checklist
  • Add some text
  • Click close or save
  • Click Add Checklist again

You will notice that the original text you entered is still there. We want whatever text was added to be cleared each time.

To achieve this, we are going to create our first side effect. We described a side effect before as arbitrary code that is executed in response to something else happening. This is inherently imperative, but sometimes this is necessary (or at least too hard to avoid).

To reset all of the values of a form we can call:

this.checklistForm.reset();

This is imperative because we are directly telling the form what to do by triggering this method. A declarative approach would be setting up a situation such that the form would automatically reset whenever it needed to based on the state of the application, rather than us manually calling reset(). Technically, we could set something like this up, but there is an effort/reward balance to this sort of thing, and at least for me making an imperative exception here is the right choice.

export default class HomeComponent {
formBuilder = inject(FormBuilder);
checklistBeingEdited = signal<Partial<Checklist> | null>(null);
checklistForm = this.formBuilder.nonNullable.group({
title: [''],
});
constructor() {
effect(() => {
const checklist = this.checklistBeingEdited();
if (!checklist) {
this.checklistForm.reset();
}
});
}
}

We now have an effect that is using the value of the checklistBeingEdited signal. Since it is using that value the effect will be triggered once initially, and then again every time checklistBeingEdited is updated.

We want to clear the form when checklistBeingEdited is closed, which means it will be null. If the value is null our reset() call will be triggered.

Try the form again now and you will see the value is cleared when you either save or close.

In the next lesson, we are going to handle doing something with our data when that save event is triggered.