Form models are the foundation of Signal Forms, serving as the single source of truth for your form data. This guide explores how to create form models, update them, and design them for maintainability.
NOTE: Form models are distinct from Angular's model() signal used for component two-way binding. A form model is a writable signal that stores form data, while model() creates inputs/outputs for parent/child component communication.
What form models solve
Forms require managing data that changes over time. Without a clear structure, this data can become scattered across component properties, making it difficult to track changes, validate input, or submit data to a server.
Form models solve this by centralizing form data in a single writable signal. When the model updates, the form automatically reflects those changes. When users interact with the form, the model updates accordingly.
Creating models
A form model is a writable signal created with Angular's signal() function. The signal holds an object that represents your form's data structure.
import { Component, signal } from '@angular/core'import { form, Field } from '@angular/forms/signals'@Component({ selector: 'app-login', imports: [Field], template: ` <input type="email" [field]="loginForm.email" /> <input type="password" [field]="loginForm.password" /> `})export class LoginComponent { loginModel = signal({ email: '', password: '' }) loginForm = form(this.loginModel)}
The form() function accepts the model signal and creates a field tree - a special object structure that mirrors your model's shape. The field tree is both navigable (access child fields with dot notation like loginForm.email) and callable (call a field as a function to access its state).
The [field] directive binds each input element to its corresponding field in the field tree, enabling automatic two-way synchronization between the UI and model.
Using TypeScript types
While TypeScript infers types from object literals, defining explicit types improves code quality and provides better IntelliSense support.
interface LoginData { email: string password: string}export class LoginComponent { loginModel = signal<LoginData>({ email: '', password: '' }) loginForm = form(this.loginModel)}
With explicit types, the field tree provides full type safety. Accessing loginForm.email is typed as FieldTree<string>, and attempting to access a non-existent property results in a compile-time error.
// TypeScript knows this is FieldTree<string>const emailField = loginForm.email// TypeScript error: Property 'username' does not existconst usernameField = loginForm.username
Initializing all fields
Form models should provide initial values for all fields you want to include in the field tree.
// Good: All fields initializedconst userModel = signal({ name: '', email: '', age: 0})// Avoid: Missing initial valueconst userModel = signal({ name: '', email: '' // age field is not defined - cannot access userForm.age})
For optional fields, explicitly set them to null or an empty value:
interface UserData { name: string email: string phoneNumber: string | null}const userModel = signal<UserData>({ name: '', email: '', phoneNumber: null})
Fields set to undefined are excluded from the field tree. A model with {value: undefined} behaves identically to {} - accessing the field returns undefined rather than a FieldTree.
Dynamic field addition
You can dynamically add fields by updating the model with new properties. The field tree automatically updates to include new fields when they appear in the model value.
// Start with just emailconst model = signal({ email: '' })const myForm = form(model)// Later, add a password fieldmodel.update(current => ({ ...current, password: '' }))// myForm.password is now available
This pattern is useful when fields become relevant based on user choices or loaded data.
Reading model values
You can access form values in two ways: directly from the model signal, or through individual fields. Each approach serves a different purpose.
Reading from the model
Access the model signal when you need the complete form data, such as during form submission:
onSubmit() { const formData = this.loginModel(); console.log(formData.email, formData.password); // Send to server await this.authService.login(formData);}
The model signal returns the entire data object, making it ideal for operations that work with the complete form state.
Reading from field state
Each field in the field tree is a function. Calling a field returns a FieldState object containing reactive signals for the field's value, validation status, and interaction state.
Access field state when working with individual fields in templates or reactive computations:
@Component({ template: ` <p>Current email: {{ loginForm.email().value() }}</p> <p>Password length: {{ passwordLength() }}</p> `})export class LoginComponent { loginModel = signal({ email: '', password: '' }) loginForm = form(this.loginModel) passwordLength = computed(() => { return this.loginForm.password().value().length })}
Field state provides reactive signals for each field's value, making it suitable for displaying field-specific information or creating derived state.
TIP: Field state includes many more signals beyond value(), such as validation state (e.g., valid, invalid, errors), interaction tracking (e.g., touched, dirty), and visibility (e.g., hidden, disabled).
Updating form models programmatically
Form models update through programmatic mechanisms:
- Replace the entire form model with
set() - Update one or more fields with
update() - Update a single field directly through field state
Replacing form models with set()
Use set() on the form model to replace the entire value:
loadUserData() { this.userModel.set({ name: 'Alice', email: 'alice@example.com', age: 30, });}resetForm() { this.userModel.set({ name: '', email: '', age: 0, });}
This approach works well when loading data from an API or resetting the entire form.
Update one or more fields with update()
Use update() to modify specific fields while preserving others:
updateEmail(newEmail: string) { this.userModel.update(current => ({ ...current, email: newEmail, }));}
This pattern is useful when you need to change one or more fields based on the current model state.
Update a single field directly with set()
Use set() on individual field values to directly update the field state:
clearEmail() { this.userForm.email().value.set('');}incrementAge() { const currentAge = this.userForm.age().value(); this.userForm.age().value.set(currentAge + 1);}
These are also known as "field-level updates." They automatically propagate to the model signal and keep both in sync.
Example: Loading data from an API
A common pattern involves fetching data and populating the model:
export class UserProfileComponent { userModel = signal({ name: '', email: '', bio: '' }) userForm = form(this.userModel) private userService = inject(UserService) ngOnInit() { this.loadUserProfile() } async loadUserProfile() { const userData = await this.userService.getUserProfile() this.userModel.set(userData) }}
The form fields automatically update when the model changes, displaying the fetched data without additional code.
Two-way data binding
The [field] directive creates automatic two-way synchronization between the model, form state, and UI.
How data flows
Changes flow bidirectionally:
User input → Model:
- User types in an input element
- The
[field]directive detects the change - Field state updates
- Model signal updates
Programmatic update → UI:
- Code updates the model with
set()orupdate() - Model signal notifies subscribers
- Field state updates
- The
[field]directive updates the input element
This synchronization happens automatically. You don't write subscriptions or event handlers to keep the model and UI in sync.
Example: Both directions
@Component({ template: ` <input type="text" [field]="userForm.name" /> <button (click)="setName('Bob')">Set Name to Bob</button> <p>Current name: {{ userModel().name }}</p> `})export class UserComponent { userModel = signal({ name: '' }) userForm = form(this.userModel) setName(name: string) { this.userModel.update(current => ({ ...current, name })) // Input automatically displays 'Bob' }}
When the user types in the input, userModel().name updates. When the button is clicked, the input value changes to "Bob". No manual synchronization code is required.
Model structure patterns
Form models can be flat objects or contain nested objects and arrays. The structure you choose affects how you access fields and organize validation.
Flat vs nested models
Flat form models keep all fields at the top level:
// Flat structureconst userModel = signal({ name: '', email: '', street: '', city: '', state: '', zip: ''})
Nested models group related fields:
// Nested structureconst userModel = signal({ name: '', email: '', address: { street: '', city: '', state: '', zip: '' }})
Use flat structures when:
- Fields don't have clear conceptual groupings
- You want simpler field access (
userForm.cityvsuserForm.address.city) - Validation rules span multiple potential groups
Use nested structures when:
- Fields form a clear conceptual group (like an address)
- The grouped data matches your API structure
- You want to validate the group as a unit
Working with nested objects
You can access nested fields by following the object path:
const userModel = signal({ profile: { firstName: '', lastName: '' }, settings: { theme: 'light', notifications: true }})const userForm = form(userModel)// Access nested fieldsuserForm.profile.firstName // FieldTree<string>userForm.settings.theme // FieldTree<string>
In templates, you bind nested fields the same way as top-level fields:
@Component({ template: ` <input [field]="userForm.profile.firstName" /> <input [field]="userForm.profile.lastName" /> <select [field]="userForm.settings.theme"> <option value="light">Light</option> <option value="dark">Dark</option> </select> `,})
Working with arrays
Models can include arrays for collections of items:
const orderModel = signal({ customerName: '', items: [{ product: '', quantity: 0, price: 0 }]})const orderForm = form(orderModel)// Access array items by indexorderForm.items[0].product // FieldTree<string>orderForm.items[0].quantity // FieldTree<number>
Array items containing objects automatically receive tracking identities, which helps maintain field state even when items change position in the array. This ensures validation state and user interactions persist correctly when arrays are reordered.
Model design best practices
Well-designed form models make forms easier to maintain and extend. Follow these patterns when designing your models.
Use specific types
Always define interfaces or types for your models as shown in Using TypeScript types. Explicit types provide better IntelliSense, catch errors at compile time, and serve as documentation for what data the form contains.
Initialize all fields
Provide initial values for every field in your model:
// Good: All fields initializedconst taskModel = signal({ title: '', description: '', priority: 'medium', completed: false})
// Avoid: Partial initializationconst taskModel = signal({ title: '' // Missing description, priority, completed})
Missing initial values mean those fields won't exist in the field tree, making them inaccessible for form interactions.
Keep models focused
Each model should represent a single form or a cohesive set of related data:
// Avoid: Mixing unrelated concernsconst appModel = signal({ // Login data email: '', password: '', // User preferences theme: 'light', language: 'en', // Shopping cart cartItems: []})
Separate models for different concerns makes forms easier to understand and reuse. Create multiple forms if you're managing distinct sets of data.
Consider validation requirements
Design models with validation in mind. Group fields that validate together:
// Good: Password fields grouped for comparisoninterface PasswordChangeData { currentPassword: string newPassword: string confirmPassword: string}
This structure makes cross-field validation (like checking if newPassword matches confirmPassword) more natural.
Plan for initial state
Consider whether your form starts empty or pre-populated:
// Form that starts empty (new user)const newUserModel = signal({ name: '', email: '',});// Form that loads existing dataconst editUserModel = signal({ name: '', email: '',});// Later, in ngOnInit:ngOnInit() { this.loadExistingUser();}async loadExistingUser() { const user = await this.userService.getUser(this.userId); this.editUserModel.set(user);}
For forms that always start with existing data, you might wait to render the form until data loads in order to avoid a flash of empty fields.