Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: David Epler
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: David Epler@dcepler )

Quick Reference For NgModel Values And Template-Driven Forms In Angular 7.2.13

By Ben Nadel on

NOTE: This post is primarily a note-to-self for future reference.

As I've demonstrated recently, template-driven forms in Angular 7.2.13 can be very dynamic. In fact, you can even listen to the reactive events using a template-driven forms model. However, in the vast majority of use-cases, I just need a good-old Text Input with a simple [(ngModel)] binding. As such, some of the details around non-text-input consumption are not always front-of-mind for me. This is why yesterday, when Charles Robertson asked me why his Radio Control wasn't working as expected, I didn't have the answer at my fingertips. In order to prevent such fumbling in the future, I wanted to create a quick reference for the basic NgModel value bindings that can be used in template-driven forms in Angular 7.2.13.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

With template-driven forms in Angular, all of the form controls are linked to the view-model using NgModel input bindings. However, with some of the controls, we have to tell Angular how the NgModel relates to other parts of the view-model (such as with Selects and Radio Buttons). In such cases, we need to use either the [value] or [ngValue] input - depending on the control - in order to create proper view-model synchronization.

To see this in action, I've created a template-driven form that uses text-inputs, radio buttons, checkboxes, single selects, and multi-selects; and then, writes the current view-model to the browser using the JSON pipe. To set the context for this experiment, let's first look at the component code:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { NgForm } from "@angular/forms";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface Genre {
  • id: string;
  • name: string;
  • adultsOnly: boolean;
  • }
  •  
  • interface Movie {
  • id: string;
  • name: string;
  • releasedAt: string;
  • }
  •  
  • interface Snack {
  • id: string;
  • name: string;
  • }
  •  
  • interface WatchOption {
  • id: string;
  • label: string;
  • }
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • templateUrl: "./app.component.htm"
  • })
  • export class AppComponent {
  •  
  • public form: {
  • favoriteGenres: {
  • action: boolean;
  • commedy: boolean;
  • documentary: boolean;
  • drama: boolean;
  • horror: boolean;
  • scifi: boolean;
  • },
  • favoriteMovie: Movie | null,
  • favoriteSnacks: Snack[],
  • user: {
  • name: string;
  • bio: string;
  • },
  • watchOption: WatchOption | null
  • };
  • public genres: Genre[];
  • public movies: Movie[];
  • public snacks: Snack[];
  • public watchOptions: WatchOption[];
  •  
  • // I initialize the app component.
  • constructor() {
  •  
  • this.genres = [
  • { id: "action", name: "Action / Adventure", adultsOnly: false },
  • { id: "commedy", name: "Commedy", adultsOnly: false },
  • { id: "documentary", name: "Documentary", adultsOnly: true },
  • { id: "drama", name: "Drama", adultsOnly: false },
  • { id: "horror", name: "Horror", adultsOnly: true },
  • { id: "scifi", name: "Sci-Fi / Fantasy", adultsOnly: false }
  • ];
  •  
  • this.movies = [
  • { id: "tt0092890", name: "Dirty Dancing", releasedAt: "1987" },
  • { id: "tt0103064", name: "Terminator 2", releasedAt: "1991" },
  • { id: "tt0093779", name: "The Princess Bride", releasedAt: "1987" },
  • { id: "tt0098635", name: "When Harry Met Sally", releasedAt: "1989" }
  • ];
  •  
  • this.snacks = [
  • { id: "jrmints", name: "Junior Mints" },
  • { id: "pmm", name: "Peanut M&Ms" },
  • { id: "popcorn", name: "Popcorn" },
  • { id: "twizzlers", name: "Twizzlers" }
  • ];
  •  
  • this.watchOptions = [
  • { id: "none", label: "I don't watch movies." },
  • { id: "one", label: "Maybe one a week" },
  • { id: "twoish", label: "One to two movies a week" },
  • { id: "lots", label: "At least one movie a day" },
  • { id: "fulltime", label: "I had to quite my job!" }
  • ];
  •  
  • this.form = {
  • favoriteGenres: {
  • action: false,
  • commedy: false,
  • documentary: false,
  • drama: false,
  • horror: false,
  • scifi: false
  • },
  • favoriteMovie: null,
  • favoriteSnacks: [],
  • user: {
  • name: "",
  • bio: ""
  • },
  • watchOption: null
  • };
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I output the current state of the form view-model.
  • public processForm( ngForm: NgForm ) : void {
  •  
  • console.group( "Form Submission" );
  • console.log( JSON.stringify( this.form, null, 4 ) );
  • console.log( ngForm );
  • console.groupEnd();
  •  
  • }
  •  
  • }

The main thing to notice here is that the form view-model is composed of complex objects, not simple strings. NgModel - and template-driven forms - are perfectly capable of mapping the simple values in our HTML form inputs onto the complex objects contained within our view-model.

Now, let's look at the template for this form. I've tried to provide sufficient commenting around each form control instance:

  • <!--
  • You don't strictly need a Form when dealing with form inputs. However, since I am
  • also using an NgModelGroup for my "user" inputs, I need to provide a "container"
  • within which that group will register itself. Plus, the Form gives you access to
  • the submit event and the NgForm export reference.
  • -->
  • <form #ngForm="ngForm" (submit)="processForm( ngForm )">
  •  
  • <h3>
  • User Profile
  • </h3>
  •  
  • <div ngModelGroup="form.user">
  •  
  • <p>
  • <label>
  • <strong>Name:</strong><br />
  • <!--
  • Text-based inputs are the bread-and-butter of template-driven forms.
  • You just bind to the view-model with [(ngModel)] and it just works.
  • -->
  • <input
  • type="text"
  • name="name"
  • [(ngModel)]="form.user.name"
  • size="20"
  • />
  • </label>
  • </p>
  •  
  • <p>
  • <label>
  • <strong>Bio:</strong><br />
  • <!--
  • Textarea are just a different form of text-based inputs. As such,
  • they also work seamlessly with [(ngModel)].
  • -->
  • <textarea
  • name="bio"
  • [(ngModel)]="form.user.bio"
  • rows="5"
  • cols="70"
  • ></textarea>
  • </label>
  • </p>
  •  
  • </div>
  •  
  • <h3>
  • Favorite Movie
  • </h3>
  •  
  • <ul>
  • <li *ngFor="let movie of movies">
  • <label>
  • <!--
  • Unlike a text-based input, Radio controls are a bit different in that
  • they have both a value AND a "state" (checked). As such, we need to
  • provide two references: the current view-model value (form.favoriteMovie)
  • and the value of the control (movie). The RadioControlValueAccessor
  • directive overrides the [value] input binding of the native control,
  • intercepting it and using it to determine if the radio's NgModel
  • value matches the [value] reference.
  • -->
  • <input
  • type="radio"
  • name="favoriteMovie"
  • [(ngModel)]="form.favoriteMovie"
  • [value]="movie"
  • />
  • {{ movie.name }} ( {{ movie.releasedAt }} )
  • </label>
  • </li>
  • <li>
  • <label>
  • <!-- We can use a [value]="null" option to clear the radio choice. -->
  • <input
  • type="radio"
  • name="favoriteMovie"
  • [(ngModel)]="form.favoriteMovie"
  • [value]="null"
  • />
  • None / Clear
  • </label>
  • </li>
  • </ul>
  •  
  • <h3>
  • Favorite Genres
  • </h3>
  •  
  • <ul>
  • <li *ngFor="let genre of genres">
  • <label>
  • <!--
  • The checkbox control is similar to the radio control in that it has
  • both a value and a state (checked); but, unlike the RadioControlValueAccessor,
  • the CheckboxControlValueAccessor directive deals exclusively with
  • Booleans. As such, it doesn't have to override any [value] input
  • binding - the NgModel view-model must reference a Boolean, which
  • is how the directive drives the [checked] state.
  • -->
  • <input
  • type="checkbox"
  • name="favoriteGenre_{{ genre.id }}"
  • [(ngModel)]="form.favoriteGenres[ genre.id ]"
  • />
  • {{ genre.name }}
  • </label>
  • </li>
  • </ul>
  •  
  • <h3>
  • Favorite Snacks
  • </h3>
  •  
  • <p>
  • <!--
  • Just as with the Radio control, the Select control seeks to match the view-
  • model value with a set of possible values. As such, we need to provide two
  • references: the current view-model value (form.favoriteSnacks) and the value
  • of the option (snackOption). However, unlike the Radio control, the Select
  • control uses [ngValue] to provide the option value in its child elements.
  • --
  • NOTE: Since we're using "multiple", the selected values are collected in
  • an Array.
  • -->
  • <select
  • name="favoriteSnacks"
  • [(ngModel)]="form.favoriteSnacks"
  • multiple
  • size="5">
  • <option *ngFor="let snackOption of snacks" [ngValue]="snackOption">
  • {{ snackOption.name }}
  • </option>
  • </select>
  • </p>
  •  
  • <h3>
  • Movie Watching Behavior
  • </h3>
  •  
  • <p>
  • <!--
  • The single Select works just like the "multiple" Select; except, it doesn't
  • gather the options in an Array - it just binds the selected option [ngValue]
  • to the view-model (form.watchOption).
  • -->
  • <select name="watchOption" [(ngModel)]="form.watchOption">
  • <option [ngValue]="null">
  • Please select an option.
  • </option>
  • <option *ngFor="let option of watchOptions" [ngValue]="option">
  • {{ option.label }}
  • </option>
  • </select>
  • </p>
  •  
  • <p>
  • <button type="submit">
  • Submit Form
  • </button>
  • </p>
  •  
  • </form>
  •  
  •  
  • <hr />
  •  
  • <h2>
  • Form View-Model Current State
  • </h2>
  •  
  • <pre><code>{{ form | json }}</code></pre>

As you can see, the general rules around NgModel are as follows:

  • Text-inputs just need [NgModel].
  • Radio buttons need [NgModel] and [value].
  • Checkboxes need [NgModel] and only work with Booleans.
  • Single selects need [NgModel] and [NgValue].
  • Mulit-selects need [NgModel] and [NgValue] and aggregate Arrays.

Now, if we run this page and fill out the form, we can see that the form's view-model contains all of the appropriate complex-object data:


 
 
 

 
 NgModel and template-driven form reference in Angular 7.2.13 - perfectly capable of binding to complex objects. 
 
 
 

Of course, this just covers the built-in Control Value Accessors; as developers we can create our own custom value accessors in order to provide additional NgModel functionality. For example, we can create a Control Value Accessor for the input type="file" that synchronizes the FileList of the input, not the file path.

Like I said at the beginning, this is primarily a note-to-self so that when I forget how NgModel works with Select, I have something to reference. Of course, if - like me - you use text-inputs in the majority of your forms, I hope that this reference can also provide value for you in the future.



Reader Comments

This is awesome. I like the way you can just add objects to the value of a radio input. It would be cool, if you could do the same thing for checkboxes and then the form just returns an array of objects for those that are checked...

I wonder how this all works for a Reactive Form? I presume it follows the same paradigm, with respect to the fact that objects can be used for values?

Reply to this Comment

@Charles,

It's funny you mention the Array and Checkbox idea -- that was the last thing that I tested right before I posted this. And, I couldn't get it to work in the way you were hoping -- that it would just add items to an array. I agree that it would be cool; but, from what I could see, it only works with Boolean values. This could just be something I was misunderstanding. But, when looking at the Control Value Accessor for the checkbox, the underlying code seems to just inspect the value as a Boolean.

Re: Reactive Forms, I am not entirely sure. As you know, I'm very new to the Reactive Forms, so I can't really speak with confidence. I assume they work similarly.

Reply to this Comment

I might see if I can add an object to the value of a Reactive form control. I will let you know the outcome.

It just seems weird, that it can only return a Boolean, seeing as Radio controls are essentially linked checkboxes.

As well as this, a standard HTML checkbox can return primitive values like strings & numbers, as well as Booleans. I guess, internally, all Angular has to do is to store the value as a JSON string and then parse it into an object, if a checkbox is defined [checked]:

https://www.w3schools.com/tags/att_input_type_checkbox.asp

Reply to this Comment

@Charles,

Yeah, especially considering that in a dynamic form, it's easy to imagine a list of checkbox being rendered based on a queried data-set. Like user preferences kind of a thing:

<div *ngFor="let pref of userPrefs">
	<input type="checkbox" ..... /> {{ pref.name }}
</div>

I'll be curious to hear if you find anything else about the Reactive Form version.

I'm also wondering if there's a way we could somehow change the behavior with a custom ControlValueAccessor. But, not sure that is possible.

Reply to this Comment

Hi Ben. I think I have cracked it, using a custom:

CheckboxControlValueAccessor:

Its not quite as clean as I would like, because I had to resort to using a counter to calculate when the checkbox is checked or not.

But, it works, never-the-less!

https://codesandbox.io/s/jnxn34o41w?fontsize=14
Reply to this Comment

@Charles,

I keep going back over this issue in my head and I think, ultimately, what keeps tripping me up is the fact that if multiple checkboxes with same name were stored in an Array, then Angular would have to check to see if a reference existed within an Array on each change digest. It seems like it may not be a very efficient workflow.

Reply to this Comment

@Charles,

Right, sorry -- I was just thinking out loud about why Angular might not want to use this approach by default.

Reply to this Comment

OK. You are correct. According to W3c, checkboxes can have the same name, but only the value of the last checkbox would be submitted, so essentially each checkbox, with the same name, would overwrite the value of the previous one.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.