- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Implement Template-driven Forms in Angular
In this Code Lab, learners turn a static contact editor into a working template-driven form in Angular. They bind form inputs to a component data model with ngModel, load and save contact data with ngSubmit, add field-level and section-level validation feedback, and support a dynamic list of phone numbers backed by an array-based model. The lab focuses on practical implementation of template-driven forms patterns used in Angular applications.
Lab Info
Table of Contents
-
Challenge
Introduction
The Scenario
You are a front-end developer on a team building a contact management application. The application already has a working contact list and navigation. Your job is to build the contact editor, the form that lets users create new contacts and update existing ones.
The editor component shell exists. The routes are wired. The data service is ready. What is missing is the form itself: the bindings that connect inputs to data, the validators that enforce required fields, the error messages that guide users, and the submit logic that saves or updates a record.
You will build all of it step by step.
Two Ways to Build Forms in Angular
Angular provides two approaches to building forms:
- Template-driven forms (using
NgModel,ngModelGroup, and directives in the HTML) - Reactive forms (using
FormGroup,FormControl, andFormBuilderin the component class).
This lab focuses on Template-driven forms, which keep most of the form logic in the template and are well-suited to straightforward data-entry scenarios like this one.
What You Will Build
This lab walks you through the following steps:
- Introduction and Project Review
Review the scenario, project structure, Contact model, and the editor's starting state - Activate the Form and Bind Inputs
Every input is bound to the component model using two-way data binding - Add Field-level Validation and Error Messages
Required fields are validated and contextual error messages appear on user interaction - Load Contact Data and Wire Save
Fields pre-populate when editing an existing contact; new and existing contacts save correctly - Group the Name Fields with ngModelGroup
Name fields are grouped so their combined validity can be tracked and displayed - Build the Dynamic Phone List
A dynamic list of phone inputs is driven by an array, with add and remove controls - Finalize and Guard Submission
Submission is blocked when the form is invalid and all errors are revealed when the user tries
Project Details
The project is in the
contact-managerdirectory. Familiarize yourself with these existing files before you begin:src/app/models/contact.model.ts— defines theContactandPhoneEntryinterfacessrc/app/services/contact.service.ts— providesgetContact,createContact,updateContact, andgetContactssrc/app/contact-list/— the contact list view
info> All your work in this lab happens in
src/app/contact-editor/:
- Component —contact-editor.component.ts
- Template —contact-editor.component.html
Contact Model
The
Contactinterface has six fields:firstName,lastName,email,jobTitle,company, andphoneNumbers. Theidfield is optional: it is absent on a new contact and present on an existing one. You will use this distinction in Step 4 to decide whether to callcreateContactorupdateContact.phoneNumbersis an array ofPhoneEntryobjects, each with atype(mobile, home, or work) and anumberstring. You will drive the dynamic phone list from this array in the later steps.
Editor Component
The component class has all its dependencies injected and its
contactmodel initialized. The four methods (loadContact,addPhone,removePhone, andonSubmit) are stubbed out with task comments marking exactly where your implementation goes. The template is a static HTML form: plain inputs with no Angular attributes, a phone section with no content, and a Save button with no guard.
Run the application now so you can see the starting state before you make any changes.
cd contact-manager npm startOpen {{localhost:4200}}/contacts/new
You will see the Contact Editor with five empty text fields and a Save button. Type into any field: nothing in the component model updates. Click Save: nothing happens. This is your starting point. Every step from here adds one layer of real behavior.
Step Summary
In this step, you reviewed the scenario, the project structure, the Contact model, and the current state of the editor component and template.
In the next step, you will activate the Angular form system, bind every input to the component model, and write the first version of the submit handler.
--- >Note: Each step contains tasks clearly marked with comments like
// Task 2.1info>If you get stuck on a task, you can view the solution in the
solutionfolder in your Filetree, or click the Task Solution link at the bottom of each task after you've attempted it. - Template-driven forms (using
-
Challenge
Activate the Form and Bind Inputs
The form is currently plain HTML. This step activates the Angular form system, connects the
<form>element to Angular's submit handling, binds every input to the component model, and adds a submit handler that lets you observe the live form value.
Concept: FormsModule and NgForm Directive
FormsModulemust be in the component'simports[]array before any template-driven form directive works. Once it is present, Angular automatically attaches anNgFormdirective to every<form>element in that component's template. No extra selector or attribute is needed on the form tag - the tag itself is enough.NgFormcreates the root of the control tree. Every<input>withnameand[(ngModel)]inside that form registers itself as a child control. You can export theNgForminstance to a template reference variable with#contactForm="ngForm". That reference gives you access tocontactForm.value,contactForm.valid, andcontactForm.invalidfrom anywhere in the template.
Concept: name + [(ngModel)] - Why Both Are Required
[(ngModel)]alone is not enough. Angular requires anameattribute on every bound input so it can register the control under a unique key in the form model. The value ofnamebecomes the key inform.value. If you omitname, Angular throws:If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.Together,
nameand[(ngModel)]do two things:- Push the component property value into the input when the component changes (one-way: class to view)
- Push the input value back to the component property on every keystroke (one-way: view to class)
The result is that
this.contact.firstNameand what is displayed in the First Name field are always the same value.
Review the current implementation
Open
contact-editor.component.ts:FormsModuleis imported at the top of the file from@angular/forms; theimports[]array is emptyNgFormis already imported - you will use it as the parameter type inonSubmitcontact: Contactis initialized with empty strings - this is the object every binding will write to// Task 2.1marks whereFormsModulegoes in theimports[]array// Task 2.4marks where theonSubmitstub body goes In this step, you activatedFormsModule, wiredngSubmitand theNgFormreference on the form tag, addednameand[(ngModel)]to all five inputs, and wrote anonSubmitstub that surfaces the live form model in the console.
In the next step, you will add validators to each field and write the error message blocks that appear when a field is touched and invalid.
-
Challenge
Add Field-level Validation and Error Messages
The form accepts any input right now, including empty submissions. This step adds validators to each required field and writes the error message blocks that appear only when the user has interacted with a field and left it invalid.
Concept: Validators as HTML Attributes
Template-driven forms use HTML validation attributes -
required,minlength,email,pattern- that Angular intercepts and converts into live validators. When a validator fails, Angular sets a key on the control's.errorsobject:| Attribute | Error key | Example check | |-----------|-----------|---------------| |
required|errors['required']|@if (field.errors?.['required'])| |minlength="2"|errors['minlength']|@if (field.errors?.['minlength'])| |email|errors['email']|@if (field.errors?.['email'])| |pattern="..."|errors['pattern']|@if (field.errors?.['pattern'])|
Concept: Control State and Error Message Timing
Every control tracked by
NgFormcarries boolean state flags:pristine/dirty- has the user changed the value from its initial state?untouched/touched- has the user focused and then left this field?valid/invalid- does the current value pass all validators?
Angular also applies CSS classes that mirror these flags (
ng-touched,ng-invalid, etc.), which you can target in CSS.The timing rule for showing error messages is:
@if (field.invalid && field.touched) {Gating on
touchedmeans errors only appear after the user has interacted with the field. Without it, a freshly rendered empty form would show errors on every required field before the user has had a chance to type anything.To access these flags in the template, export the control as a template reference variable:
<input name="firstName" ...... required #firstName="ngModel" />The identifier
#firstNameis now available anywhere in the same template.Review the current implementation
Open
contact-editor.component.html- All five inputs have
nameand[(ngModel)]from Step 2 - the bindings are in place - Each input still has no validators and no template reference variable
- Each
<!-- Task 3.x -->comment marks the location for the validator attributes and the error block
--- With both name fields validated, the next input needs a second validator —
emailchecks format, and its reference variable requires a different name to avoid clashing with thenameattribute.Email is validated for both presence and format. Company is intentionally left without a validator since not every field needs to be required.
Job Title follows the same pattern with just
required. All required fields are now validated. Verify all three validators working together before moving on.--- Navigate to {{localhost:4200}}/contacts/new
Click into First Name and immediately tab away - "First name is required." appears. Type one character - the message changes to "First name must be at least 2 characters." Type a second character - the error clears and the red border disappears.
Repeat the process for Email by typing
badformat- the format error appears. Correct it to[email protected]- the error clears.Click Save Contact and check the console -
form.validis stillfalsebecause the required fields are empty.--- In this step, you added
required,minlength, andemailvalidators to four inputs, exported each as a template reference variable, and wrote@if-gated error blocks that display the right message based on which validator failed and whether the user has touched the field.In the next step, you will implement
loadContactso the edit route pre-populates the form, and replace the console log stub with a real save handler. -
Challenge
Load Contact Data and Wire Save
The form can validate input but cannot yet load existing contacts or save anything. This step implements both:
loadContactfetches a contact and the[(ngModel)]bindings propagate the data into every input automatically, andonSubmitbranches oncontact.idto call the correct service method.
Concept: Assignment as Form Population
In a template-driven form, you do not patch controls individually. Because every input already has
[(ngModel)]="contact.firstName"(and so on), updatingthis.contacton the component class is all that is needed to populate the form:this.contact = loadedContact;Angular's change detection sees the property change and pushes the new values into every bound input in the same cycle. No iterating over controls, no
patchValue, nosetValue.
Create vs Update Branch
ContactServicehas two write methods:createContactfor new records andupdateContactfor existing ones. TheContactinterface usesid?: string- the?meansidisundefinedon a new contact and a string on an existing one. A ternary onthis.contact.idselects the right method:const save$ = this.contact.id ? this.contactService.updateContact(this.contact) : this.contactService.createContact(this.contact);
Review the current implementation
Open
contact-editor.component.tsngOnInitalready reads the route parameter and callsloadContact(id)when anidis presentloadContactalready subscribes togetContact- thenextcallback is empty with// Task 4.1onSubmithas// Task 4.2where the guard, branch, and subscribe logic go; the console log from Step 2 is replaced entirely The form now populates when editing. Next, replace the console log stub with a real save handler that branches on whether the contact is new or existing. Both load and save are implemented. Run the application to verify the full create and edit flows.
Navigate to {{localhost:4200}}/contacts . Click Edit on any contact. All five fields populate instantly with the contact's data. Modify the Email field and click Save Contact. The app navigates to
/contactsand shows the updated email. Then navigate to/contacts/new, fill in all required fields, and click Save Contact. The new contact appears in the list. In this step, you implementedloadContactby assigning the API result tothis.contact(which populated all bound inputs automatically), and replaced the stubonSubmitwith a complete handler that guards, branches, subscribes, and navigates.In the next step, you will wrap the name fields in
ngModelGroupto add a group-level validation state that spans both fields. -
Challenge
Group the Name Fields with `ngModelGroup`
Right now
firstNameandlastNameare independent controls. This step groups them underngModelGroupso you can track and display validation state at the section level - showing a single banner when either name field is incomplete, instead of relying solely on per-field messages.Concept: ngModelGroup
ngModelGroupapplied to a container element creates a named sub-group within theNgFormcontrol tree. The controls inside register as children of the group rather than directly on the root form.The group computes its own
valid,invalid,touched, anddirtyby rolling up the state of all child controls. The group is invalid if any child is invalid. The group is touched if any child has been focused and blurred. These rolled-up properties are available on a template reference variable exported with#nameGroup="ngModelGroup".One important side effect:
form.valuechanges shape. Before grouping:{ "firstName": "Jane", "lastName": "Doe", "email": "..." }After adding
ngModelGroup="name":{ "name": { "firstName": "Jane", "lastName": "Doe" }, "email": "..." }this.contactis unaffected because[(ngModel)]writes directly tocontact.firstNameandcontact.lastNameregardless of the group wrapper. TheonSubmithandler usesthis.contactas the payload, so no changes are needed there.
Review the current implementation
Open
contact-editor.component.htmlfirstNameandlastNameare complete with validators and@iferror blocks from Step 3<!-- Task 5.1 -->marks the two locations: one where the opening<fieldset>tag goes (above the firstName block) and one where the closing tag and group banner go (below the lastName block)
The name group is in place. Run the application to see the group-level banner working alongside the individual field errors.
Navigate to
{{localhost:4200}}/contacts/new. Click into First Name, press Tab to reach Last Name without typing, then press Tab again to leave. The individual "First name is required." error appears inside the fieldset. The amber group banner also appears below it. Fill inJanefor First Name andDoefor Last Name - both errors clear and the banner disappears. In this step, you wrapped the name fields in a<fieldset ngModelGroup>and added a group-level@ifbanner that reads rolled-up state from the#nameGroupreference variable.In the next step, you will build the dynamic phone number list - a repeated set of inputs driven by an array on the component model.
-
Challenge
Build the Dynamic Phone List
The phone section is empty. This step implements the two array mutation methods and builds the
@forloop that renders one input row per entry. You will also add validators and error messages to each phone row, applying the sametouched/invalidpattern from Step 3 - but this time inside a loop where every input must have a uniquename.Concept: Unique Names in Repeated Controls
Angular uses the
nameattribute to register each control inNgForm. When you render inputs inside@for, every iteration must produce a differentname. If two inputs share the samename, the second one silently overwrites the first in the form model - both rows will always show the same value.Angular's
@forblock provides an implicit$indexvariable that holds the current iteration number. Embed it in thenameusing property binding:[name]="'phoneNumber_' + $index"This produces
phoneNumber_0,phoneNumber_1, and so on - each a distinct key. The same pattern applies toidandforattributes so that each label remains correctly associated with its input.Template reference variables inside
@forare scoped to their iteration.#phoneNum="ngModel"in the block refers only to the control in that row. Each row has its own independent validation state.
Concept: Array Mutation Drives the DOM
When
addPhonepushes ontocontact.phoneNumbers, Angular's change detection re-runs the@forloop and renders a new row. WhenremovePhonesplices an entry out, the corresponding row is destroyed and its control is deregistered fromNgForm. Form validity is recalculated automatically after both operations.
Review the current implementation
Open
contact-editor.component.tsaddPhone()has a// Task 6.1comment with no implementationremovePhone(index: number)has a// Task 6.2comment with no implementationPhoneEntryis already imported from the model
---
addPhoneis done. Now implement the removal side inremovePhoneso rows can be taken out of the list. Both array methods are ready. Now build the template loop that renders those entries as actual form inputs. The loop renders each phone entry. Now add the button that triggersaddPhoneso users can add new rows. The phone list is complete. Run the application to test adding, removing, and validating phone rows.Navigate to {{localhost:4200}}/contacts/new . Click + Add Phone Number three times. Three rows appear. Click Remove on the middle row - it disappears and the remaining two rows stay in place. In the first row, type
123in the Number field and tab away - the pattern error appears. Change the value to9876543210- the error clears. Fill in all required text fields and click Save Contact. The new contact is saved with the phone number.In this step, you implemented
addPhoneandremovePhone, built the@forphone loop with$index-keyednameattributes, added validators and error messages to each phone row, and wired the Add and Remove buttons.In the next step, you will finalize form submission: revealing all errors when the user submits blank, disabling the Save button while the form is invalid, and filtering blank phone entries from the payload.
-
Challenge
Finalize and Guard Submission
The form validates correctly as the user types, but three gaps remain. Clicking Save on a blank form returns silently with no visible feedback. Blank phone rows can reach the API. The Save button is always enabled even when the form is invalid. This step closes all three.
Concept: markAllAsTouched
Error messages are gated on
field.touched. A user who clicks Save immediately, without interacting with any field, sees no errors - theinvalidcondition is true buttouchedis false, so every@ifblock evaluates tofalse.NgForm.markAllAsTouched()walks the entire control tree and sets every control totouchedin a single call. Calling it before thereturninside theform.invalidguard forces every@if (field.touched && field.invalid)condition to becometrue, making all error messages visible at once.
Concept: Payload Filtering
The
requiredvalidator on each phone number input blocks submission when a row is blank. As a further safeguard before calling the service, filter blank entries out of the payload. This ensures no empty phone numbers reach the API even if the validation state is ambiguous.
Concept: Binding [disabled] to Form Validity
contactForm.invalidistruewhenever any control in the tree fails a validator. Binding it to[disabled]on the Save button gives users an immediate visual signal that the form is not ready. Angular re-evaluates the binding on every change detection cycle, so the button enables automatically as soon as all validators pass.
Review the current implementation
Open
contact-editor.component.tsonSubmithas anif (form.invalid) { return; }guard from Task 4.2 — you will addmarkAllAsTouched()inside it before thereturnonSubmitusesthis.contactdirectly in thesave$ternary from Task 4.2 — you will replace it with a filtered payload
Open
contact-editor.component.html<!-- Task 7.3 -->marks the Save button - the[disabled]binding goes on that element
Errors now surface on a blank submit. Next, make sure blank phone rows don't slip through to the API by filtering the payload before saving.
The payload is clean. Now give users a visual signal by disabling the Save button whenever the form is invalid. All three gaps are closed. Run the application for a final end-to-end check.
Navigate to http://localhost:4200/contacts/new.
The Save Contact button is dimmed. Click it without filling anything in - all required field errors appear simultaneously and the name group banner appears. Fill in
Jane,Doe,[email protected], andEngineer. The button becomes active. Click + Add Phone Number, leave the number blank, and click Save Contact - the blank phone row highlights red and submission is blocked. Enter9876543210- the button becomes active again. Click Save Contact. The contact is saved and the app navigates to/contacts.In this step, you added
markAllAsTouched()to surface all errors on a blank submit, built a filtered payload that strips blank phone entries before saving, and bound[disabled]tocontactForm.invalidto give users immediate feedback about form completeness.In the next step, you will verify the complete form end-to-end across both the create and edit flows.
-
Challenge
Conclusion and Next Steps
You started with a static HTML form and built a fully functional contact editor using Angular's template-driven form system.
Key Concepts to Take Away
- Template-driven forms keep logic in the template.
NgForm,NgModel, andNgModelGroupare directives that Angular attaches automatically based on attributes you write in HTML — no explicit control creation in the component class. name+[(ngModel)]is the minimum unit. Both are required for a control to register in the form tree and participate in validity, value, and state tracking.- Control state flags drive UX timing. Gating error messages on
touchedensures they appear only after the user has interacted with a field, not on initial render. ngModelGrouprolls up state. A group is invalid if any child is invalid and touched if any child has been focused — giving you section-level feedback without extra logic.$indexis essential in repeated controls. Every input rendered inside@formust have a uniquename; embedding$indexin the binding ensures this automatically.
Next Steps
Reactive Forms — Rebuild the same editor using
FormBuilder,FormGroup, andFormArray. Reactive forms place all control creation and validator wiring in the component class, making them easier to unit test and well-suited to forms whose structure changes at runtime.Custom Validators — Write a directive that implements the
Validatorinterface and apply it as an attribute, the same wayrequiredandminlengthare used. A practical example for this form would be a validator that rejects duplicate phone numbers within the same contact.Unit Testing — Test
ContactEditorComponentwithComponentFixture. TriggerngSubmit, assert thatContactService.createContactwas called with the correct payload, and verify that error messages appear in the DOM after an invalid submit.Cross-field Validation — Add a custom group-level validator to the
ngModelGroupthat checks a condition spanning bothfirstNameandlastName, such as preventing both fields from being identical.Congratulations on completing the lab!
- Template-driven forms keep logic in the template.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.