Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Intermediate
Last updated
Jun 24, 2026
Duration
1h 0m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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, and FormBuilder in 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:

    1. Introduction and Project Review
      Review the scenario, project structure, Contact model, and the editor's starting state
    2. Activate the Form and Bind Inputs
      Every input is bound to the component model using two-way data binding
    3. Add Field-level Validation and Error Messages
      Required fields are validated and contextual error messages appear on user interaction
    4. Load Contact Data and Wire Save
      Fields pre-populate when editing an existing contact; new and existing contacts save correctly
    5. Group the Name Fields with ngModelGroup
      Name fields are grouped so their combined validity can be tracked and displayed
    6. Build the Dynamic Phone List
      A dynamic list of phone inputs is driven by an array, with add and remove controls
    7. 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-manager directory. Familiarize yourself with these existing files before you begin:

    • src/app/models/contact.model.ts — defines the Contact and PhoneEntry interfaces
    • src/app/services/contact.service.ts — provides getContact, createContact, updateContact, and getContacts
    • src/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 Contact interface has six fields: firstName, lastName, email, jobTitle, company, and phoneNumbers. The id field 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 call createContact or updateContact.

    phoneNumbers is an array of PhoneEntry objects, each with a type (mobile, home, or work) and a number string. 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 contact model initialized. The four methods (loadContact, addPhone, removePhone, and onSubmit) 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 start
    

    Open {{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.1

    info>If you get stuck on a task, you can view the solution in the solution folder in your Filetree, or click the Task Solution link at the bottom of each task after you've attempted it.

  2. 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

    FormsModule must be in the component's imports[] array before any template-driven form directive works. Once it is present, Angular automatically attaches an NgForm directive 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.

    NgForm creates the root of the control tree. Every <input> with name and [(ngModel)] inside that form registers itself as a child control. You can export the NgForm instance to a template reference variable with #contactForm="ngForm". That reference gives you access to contactForm.value, contactForm.valid, and contactForm.invalid from anywhere in the template.


    Concept: name + [(ngModel)] - Why Both Are Required

    [(ngModel)] alone is not enough. Angular requires a name attribute on every bound input so it can register the control under a unique key in the form model. The value of name becomes the key in form.value. If you omit name, 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, name and [(ngModel)] do two things:

    1. Push the component property value into the input when the component changes (one-way: class to view)
    2. Push the input value back to the component property on every keystroke (one-way: view to class)

    The result is that this.contact.firstName and what is displayed in the First Name field are always the same value.


    Review the current implementation

    Open contact-editor.component.ts:

    • FormsModule is imported at the top of the file from @angular/forms; the imports[] array is empty
    • NgForm is already imported - you will use it as the parameter type in onSubmit
    • contact: Contact is initialized with empty strings - this is the object every binding will write to
    • // Task 2.1 marks where FormsModule goes in the imports[] array
    • // Task 2.4 marks where the onSubmit stub body goes In this step, you activated FormsModule, wired ngSubmit and the NgForm reference on the form tag, added name and [(ngModel)] to all five inputs, and wrote an onSubmit stub 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.

  3. 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 .errors object:

    | 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 NgForm carries 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 touched means 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 #firstName is now available anywhere in the same template.

    Review the current implementation

    Open contact-editor.component.html

    • All five inputs have name and [(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 — email checks format, and its reference variable requires a different name to avoid clashing with the name attribute.

    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.valid is still false because the required fields are empty.

    --- In this step, you added required, minlength, and email validators 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 loadContact so the edit route pre-populates the form, and replace the console log stub with a real save handler.

  4. 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: loadContact fetches a contact and the [(ngModel)] bindings propagate the data into every input automatically, and onSubmit branches on contact.id to 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), updating this.contact on 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, no setValue.


    Create vs Update Branch

    ContactService has two write methods: createContact for new records and updateContact for existing ones. The Contact interface uses id?: string - the ? means id is undefined on a new contact and a string on an existing one. A ternary on this.contact.id selects 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.ts

    • ngOnInit already reads the route parameter and calls loadContact(id) when an id is present
    • loadContact already subscribes to getContact - the next callback is empty with // Task 4.1
    • onSubmit has // Task 4.2 where 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 /contacts and 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 implemented loadContact by assigning the API result to this.contact (which populated all bound inputs automatically), and replaced the stub onSubmit with a complete handler that guards, branches, subscribes, and navigates.

    In the next step, you will wrap the name fields in ngModelGroup to add a group-level validation state that spans both fields.

  5. Challenge

    Group the Name Fields with `ngModelGroup`

    Right now firstName and lastName are independent controls. This step groups them under ngModelGroup so 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

    ngModelGroup applied to a container element creates a named sub-group within the NgForm control 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, and dirty by 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.value changes shape. Before grouping:

    { "firstName": "Jane", "lastName": "Doe", "email": "..." }
    

    After adding ngModelGroup="name":

    { "name": { "firstName": "Jane", "lastName": "Doe" }, "email": "..." }
    

    this.contact is unaffected because [(ngModel)] writes directly to contact.firstName and contact.lastName regardless of the group wrapper. The onSubmit handler uses this.contact as the payload, so no changes are needed there.


    Review the current implementation

    Open contact-editor.component.html

    • firstName and lastName are complete with validators and @if error 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 in Jane for First Name and Doe for 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 @if banner that reads rolled-up state from the #nameGroup reference 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.

  6. Challenge

    Build the Dynamic Phone List

    The phone section is empty. This step implements the two array mutation methods and builds the @for loop that renders one input row per entry. You will also add validators and error messages to each phone row, applying the same touched/invalid pattern from Step 3 - but this time inside a loop where every input must have a unique name.

    Concept: Unique Names in Repeated Controls

    Angular uses the name attribute to register each control in NgForm. When you render inputs inside @for, every iteration must produce a different name. If two inputs share the same name, the second one silently overwrites the first in the form model - both rows will always show the same value.

    Angular's @for block provides an implicit $index variable that holds the current iteration number. Embed it in the name using property binding:

    [name]="'phoneNumber_' + $index"
    

    This produces phoneNumber_0, phoneNumber_1, and so on - each a distinct key. The same pattern applies to id and for attributes so that each label remains correctly associated with its input.

    Template reference variables inside @for are 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 addPhone pushes onto contact.phoneNumbers, Angular's change detection re-runs the @for loop and renders a new row. When removePhone splices an entry out, the corresponding row is destroyed and its control is deregistered from NgForm. Form validity is recalculated automatically after both operations.


    Review the current implementation

    Open contact-editor.component.ts

    • addPhone() has a // Task 6.1 comment with no implementation
    • removePhone(index: number) has a // Task 6.2 comment with no implementation
    • PhoneEntry is already imported from the model

    --- addPhone is done. Now implement the removal side in removePhone so 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 triggers addPhone so 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 123 in the Number field and tab away - the pattern error appears. Change the value to 9876543210 - 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 addPhone and removePhone, built the @for phone loop with $index-keyed name attributes, 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.

  7. 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 - the invalid condition is true but touched is false, so every @if block evaluates to false.

    NgForm.markAllAsTouched() walks the entire control tree and sets every control to touched in a single call. Calling it before the return inside the form.invalid guard forces every @if (field.touched && field.invalid) condition to become true, making all error messages visible at once.


    Concept: Payload Filtering

    The required validator 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.invalid is true whenever 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.ts

    • onSubmit has an if (form.invalid) { return; } guard from Task 4.2 — you will add markAllAsTouched() inside it before the return
    • onSubmit uses this.contact directly in the save$ 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], and Engineer. 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. Enter 9876543210 - 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] to contactForm.invalid to 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.

  8. 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, and NgModelGroup are 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 touched ensures they appear only after the user has interacted with a field, not on initial render.
    • ngModelGroup rolls 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.
    • $index is essential in repeated controls. Every input rendered inside @for must have a unique name; embedding $index in the binding ensures this automatically.

    Next Steps

    Reactive Forms — Rebuild the same editor using FormBuilder, FormGroup, and FormArray. 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 Validator interface and apply it as an attribute, the same way required and minlength are used. A practical example for this form would be a validator that rejects duplicate phone numbers within the same contact.

    Unit Testing — Test ContactEditorComponent with ComponentFixture. Trigger ngSubmit, assert that ContactService.createContact was 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 ngModelGroup that checks a condition spanning both firstName and lastName, such as preventing both fields from being identical.

    Congratulations on completing the lab!

About the author

Amar Sonwani is a software architect with more than twelve years of experience. He has worked extensively in the financial industry and has expertise in building scalable applications.

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.

Get started with Pluralsight