Using Form Controls Without FormsModule Or NgModel In Angular 2.4.1
CAUTION / UPDATE - January 7, 2017: Something I didn't notice at first is that the cursor in the text-based inputs is forced to the end. So, if you try to edit the middle of a textarea, for example, your cursor is forced to the end of the input. This is obviously sub-optimal. I'll see if I can come up with a way to fix this.
There's no doubt that using ngModel makes it much easier to work with form controls in Angular 2. But sometimes, I'm not really doing a lot of form work. Sometimes, I just have a random Input or a Select box and I don't want to have to load the entire FormsModule or the ReactiveFormsModule for a simple form-based interaction. Luckily, I don't have to. The basic data-binding functionality of ngModel can be achieved through Angular's property and event binding syntax. It's definitely not as simple as ngModel; but, for a one-off need, it's not too bad.
Run this demo in my JavaScript Demos project on GitHub.
The fundamental goal of ngModel is to synchronize a Form interface with an underlying data model. ngModel also provides a bunch of functionality around state and validation; but, the core concept is data synchronization. Without ngModel, we have to use property bindings to push state into the user interface (UI). Then, we have to use event bindings to drive user interactions back into the state.
With basic text inputs (Input and Textarea), this workflow is actually quite simple. We can use the [value] property binding to update the UI; then, we can use the (input) event binding to update the state as the user enters text data in the form control:
<input [value]="data" (input)="data = $event.target.value" />
As you can see, the [value] binding drives "data" into the UI; then, in the (input) event handler, we're pushing the Input value back into "data", keeping everything synchronized.
Doing this with a basic Input element or a Textarea element is trivial. But, the rest of the form controls, like Radio groups and Select menus, require a little more effort. And, I'm sure that with the more complicated controls, there are several ways to accomplish the same outcome. The following code only represents the approaches that I could get working - this is not intended to be an exhaustive exploration of form controls.
// Import the core angular services.
import { Component } from "@angular/core";
interface Friend {
id: number;
name: string;
}
@Component({
moduleId: module.id,
selector: "my-app",
styleUrls: [ "./app.component.css" ],
template:
`
<input type="text" [value]="textValue" (input)="textValue = $event.target.value;" />
<input type="text" [value]="textValue" (input)="textValue = $event.target.value;" />
<br /><br />
<textarea [value]="textareaValue" (input)="textareaValue = $event.target.value;"></textarea>
<textarea [value]="textareaValue" (input)="textareaValue = $event.target.value;"></textarea>
<br /><br />
<label>
<input type="checkbox" [checked]="( checkboxValue === true )" (change)="checkboxValue = $event.target.checked;" />
Do it?
</label>
<br />
Set to
<a (click)="checkboxValue = true;">True</a> or
<a (click)="checkboxValue = false;">False</a>
<br /><br />
<template ngFor let-friend [ngForOf]="friends">
<label>
<input type="radio" name="friend-group" [checked]="( radioValue === friend )" (change)="radioValue = friend;" />
{{ friend.name }}
</label>
<br />
</template>
<a (click)="radioValue = friends[ 0 ];">Select the first friend.</a><br />
<a (click)="radioValue = null;">Set to null.</a><br />
<br />
<select (change)="selectValue = ( $event.target.value && friends[ +$event.target.value ] || null );">
<option>No Friends</option>
<template ngFor let-friend [ngForOf]="friends" let-index="index">
<option [value]="index" [selected]="( selectValue === friend )">
{{ friend.name }}
</option>
</template>
</select>
<br />
<a (click)="selectValue = friends[ 0 ];">Select the first friend.</a><br />
<a (click)="selectValue = null;">Set to null.</a><br />
<br />
<hr />
<h3>
Debugging
</h3>
<strong>Text Value</strong>: {{ textValue | json }}<br />
<strong>Textarea Value</strong>: {{ textareaValue | json }}<br />
<strong>Checkbox Value</strong>: {{ checkboxValue | json }}<br />
<strong>Radiobox Value</strong>: {{ radioValue | json }}<br />
<strong>Select Value</strong>: {{ selectValue | json }}<br />
`
})
export class AppComponent {
public checkboxValue: boolean;
public friends: Friend[];
public radioValue: Friend;
public selectValue: Friend;
public textareaValue: string;
public textValue: string;
// I initialize the component.
constructor() {
this.checkboxValue = false;
this.radioValue = null;
this.selectValue = null;
this.textareaValue = "Textarea!";
this.textValue = "Text!";
this.friends = [
{
id: 1,
name: "Sarah"
},
{
id: 2,
name: "Tricia"
},
{
id: 3,
name: "Kim"
}
];
}
}
The top half of the template represents the make-shift, ngModel-less form control interactions. Then, the bottom half of the template is JSON-based output of the data model intended to demonstrate that the data is truly being synchronized behind the scenes. And, when we run this code and interact with application, we can see that all the data is being updated properly:
Now that we see it working, I wanted to offer a bit more detail on my approach in the more complex situations. I'll skip the Input and Textarea elements since those are mundane.
Using the Checkbox:
<input
type="checkbox"
[checked]="( checkboxValue === true )"
(change)="checkboxValue = $event.target.checked;"
/>
The first thing you might notice here is that there is no [value] binding. You can certainly use a [value] binding with a checkbox if you want to; but, I didn't really have a need for it. Ultimately, I just let the checked nature of the checkbox drive the data model - whenever the state of checkbox changes, I simply update the data model to reflect the checked or unchecked state.
Using the Radiobox:
<template ngFor let-friend [ngForOf]="friends">
<label>
<input
type="radio"
name="friend-group"
[checked]="( radioValue === friend )"
(change)="radioValue = friend;"
/>
{{ friend.name }}
</label>
</template>
In this case, I'm using a collection of items to drive the UI for the radiobox control group. And, once again, you may notice that I don't have any [value] bindings; but, I'm not quite running off of the checked state of the input, like I was with the the checkbox. Instead, I'm letting the ngFor loop variables drive the data synchronization. I know that the radiobox should be checked if the current ngFor iteration item matches the data model. And, when the radiobox is toggled [on], I know that I can push the ngFor iteration item back into the data model.
Using the Select:
<select (change)="selectValue = ( $event.target.value && friends[ +$event.target.value ] || null );">
<option>No Friends</option>
<template ngFor let-friend [ngForOf]="friends" let-index="index">
<option
[value]="index"
[selected]="( selectValue === friend )">
{{ friend.name }}
</option>
</template>
</select>
The Select control is by far the most complicated control to use. In this case, we're still using a collection of items to drive the UI; but, unlike with the Radiobox example, we can't bind event handlers to the individual Option elements. As such, we have to find a way to associate each Option with a Select-level event.
Borrowing from the Angular 1.x ngOptions directive, I am using the index of the collection iteration to define the Option value. Then, when the Select state changes, I know that the selected value is actually the index of the item that the user wants to select. As such, I can cast the selected value to a number (using the + operator) and push the collection item back into the data model.
In this example, I'm logically-OR'ing the whole value assignment with "null" in order to account for the first Option, sometimes called the "null option". Of course, Select boxes can get even more complicated than this. There may be several static Options and several data-driven options in the same Select. And, the Select may allow for multi-selections. In either case, more elbow-grease would have to be applied; but, it would certainly still be possible without ngModel.
If I was building an Angular 2 application that had forms, there's no doubt that I'd import the FormsModule and use ngModel to drive the form-based functionality. But, if all I need is a "search input" or some other one-off form control, importing the whole FormsModule seems like overkill. Luckily, I can implement the basic data synchronization functionality of ngModel using the core [property] and (event) bindings in Angular 2.
Want to use code from this post? Check out the license.
Reader Comments
Nice article, anf thanks to share your thoughts as they evolve.
"ngModel also provides a bunch of functionality around state and validation; but, the core concept is data synchronization. "
It would be nice to ba able to seperate the 2. What if we now need to validate this ? It is uncommon for a select but possible for instance there could be interdependant fields and not all combinations ara allowed. I find myself doing hackish approaches to this. Either validate "by hand", or tweak ngModel to prevents its magic and get only the validation part.
(btw your demo is currently broken because of an angry server that responds with 403 to anything)