• Google Developer Expert (GDE) in Angular
  • Author of Mastering Angular Reactive Forms
  • Educator & Technical Content Creator
  • Senior Angular Developer  @ ASI
  • Co-organizer of Angular Athens Meetup

Fanis Prodromou

Code. Teach. Community. Angular.

https://blog.profanis.me

/prodromouf

@prodromouf

Introduction

to

Signal Forms

  • Simple -> Complex Form
     
  • Mental Model
     
  • Validators/Logic
     
  • Split Form
ng new signal-forms-app

Login

Register

Create a

Contract

Login

Register

Create a

Contract

<form>
  <input type="email" placeholder="Email" />

  <input type="password" placeholder="Password" />

  <button>Login</button>
</form>
export interface LoginFormModel {
  email: string;
  password: string;
}
loginModel = signal<LoginFormModel>({
  email: '',
  password: '',
});
import { FormField, form } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on next slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel);
}
import { FormField, form } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on next slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel);
}
import { FormField, form } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on next slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel);
}
<form>
  <input
    [formField]="loginForm.email"
    type="email"
    placeholder="Enter your email"
  />

  <input
    [formField]="loginForm.password"
    type="password"
    placeholder="Enter your password"
  />

  <button>Login</button>
</form>
import { FormField, form } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on next slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel);
}

Validators

import { FormField, email, 
        form, required  } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on previous slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel, (rootPath) => {
	// validators go here    
  });
}
import { FormField, email, 
        form, required  } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on previous slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel, (rootPath) => {
    required(rootPath.email);
  });
}
import { FormField, email, 
        form, required  } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on previous slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel, (rootPath) => {
    required(rootPath.email);
    required(rootPath.password);
  });
}
import { FormField, email, 
        form, required  } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on previous slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel, (rootPath) => {
    required(rootPath.email);
    required(rootPath.password);
    email(rootPath.email);
  });
}

How can we walk trough the form fields?

By simply using the dot (.) notation

root

password

email

By simply using the dot (.) notation

loginForm

email

password

By simply using the dot (.) notation

loginForm

loginForm.email

password

By simply using the dot (.) notation

loginForm

loginForm.email

loginForm.password

How can we access the state?

loginForm()

email

password

By simply using parenthesis ()

loginForm.email()

password

By simply using parenthesis ()

loginForm()

By simply using parenthesis ()

loginForm.email()

loginForm.password()

loginForm()

By simply using parenthesis ()

loginForm.email()

loginForm.password()

loginForm()

...().value()
...().valid()
...().errors()
...().disabled()
...().hidden()
...().submittedStatus()
...

Let's access the state!

<form>
  <input
    [formField]="loginForm.email"
    type="email"
    placeholder="Enter your email"
  />
  @if (loginForm.email().touched() && loginForm.email().invalid()) {
    <p>The email is invalid.</p>
  }

  <input
    [formField]="loginForm.password"
    type="password"
    placeholder="Enter your password"
  />
  <!-- same as above -->

  <button [disabled]="loginForm().invalid()">Login</button>
</form>
<form>
  <input
    [formField]="loginForm.email"
    type="email"
    placeholder="Enter your email"
  />
  @for (error of loginForm.email().errors(); track error.kind) {
    @if (error.kind === "required") {
      <p>This field is required.</p>
    }
  }

  <input
    [formField]="loginForm.password"
    type="password"
    placeholder="Enter your password"
  />
  <!-- same as above -->

  <button [disabled]="loginForm().invalid()">Login</button>
</form>
<form>
  <input
    [formField]="loginForm.email"
    type="email"
    placeholder="Enter your email"
  />
  @for (error of loginForm.email().errors(); track error.kind) {
    @if (error.kind === "required") {
      <p>This field is required.</p>
    }
	@if (error.kind === "email") {
      <p>This field is invalid.</p>
    }
    @if (error.kind === "minlength") {
      <p>This field is short.</p>
    }
  }

  <button [disabled]="loginForm().invalid()">Login</button>
</form>
import { FormField, email, 
        form, required  } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `on previous slide :)`
})
export class LoginComponent {
  loginForm = form(this.loginModel, (rootPath) => {
    required(rootPath.email, {
      message: 'Email is required',
    });
    required(rootPath.password, {
      message: 'Password is required',
    });
    email(rootPath.email, {
      message: 'Email is not valid',
    });
  });
}
<form>
  <input
    [formField]="loginForm.email"
    type="email"
    placeholder="Enter your email"
  />
  @for (error of loginForm.email().errors(); track error.kind) {
    <p>{{ error.message }}</p>
  }
  <button [disabled]="loginForm().invalid()">Login</button>
</form>

Submit Form

import { submit } from '@angular/forms/signals';

submit(?, ?)
import { submit } from '@angular/forms/signals';

submit(myForm, ?)
import { submit } from '@angular/forms/signals';

submit(myForm, () => actionHandler)
import { submit } from '@angular/forms/signals';

submit(this.loginForm, () => actionHandler)
import { submit } from '@angular/forms/signals';

submit(this.loginForm, (form) => {
  try {
    return undefined;
  } catch (error) {
    // return an error
  }
})
import { submit } from '@angular/forms/signals';

submit(this.loginForm, async (form) => {
  try {
    await firstValueFrom(this.loginService.login(form().value()));
    return undefined;
  } catch (error) {
    // return an error
  }
})
import { submit } from '@angular/forms/signals';

submit(this.loginForm, async (form) => {
  try {
    await firstValueFrom(this.loginService.login(form().value()));
    return undefined;
  } catch (error) {
	return {
      message: (error as Error).message,
      field: this.loginForm.email,
      kind: 'submit',
    };
  }
})
import { submit } from '@angular/forms/signals';

submitForm(event: Event) {
  event.preventDefault();

  submit(this.loginForm, async (form) => {
    try {
      await firstValueFrom(this.loginService.login(form().value()));
      return undefined;
    } catch (error) {
      return {
        message: (error as Error).message,
        field: this.loginForm.email,
        kind: 'submit',
      };
    }
  });
}
<button
    type="submit"
    [disabled]="
      loginForm().invalid() ||
      loginForm().submitting()">
    Login
</button>

Login

Register

Create a

Contract

registrationForm = form(this.formModel, (path) => {
	// validators and logic goes here
});
registrationForm = form(this.formModel, (path) => {
	// validators and logic goes here
});
registrationForm = form(this.formModel, (path) => {
	// validators and logic goes here
});
registrationForm = form(this.formModel, (path) => {
	// validators and logic goes here
});

Approach #1

Approach #1

Approach #1

validate(path, (ctx) => {

    return ifTrue 
        ? undefined 
        : customError();
});
validate(path, (ctx) => {

    return ifTrue 
      	? undefined 
    	: customError();
});
validate(path, (ctx) => {

    return ifTrue 
      	? undefined 
    	: customError();
});

Schema Runs Once ⏳

Validation Must Be Reactive 

The ctx Argument 🔑

validate(path, (ctx) => {

    return ifTrue 
      	? undefined 
    	: customError();
});
validate(rootPath, (ctx) => {
    const password = ctx.value().password;
    const confirmPassword = ctx.value().confirmPassword;

    return confirmPassword === password
      ? undefined
      : {
          kind: 'confirmationPassword',
        };
  });
validate(rootPath, (ctx) => {
    const password = ctx.value().password;
    const confirmPassword = ctx.value().confirmPassword;

    return confirmPassword === password
      ? undefined
      : {
          kind: 'confirmationPassword',
        };
  });
validate(rootPath, (ctx) => {
    const password = ctx.value().password;
    const confirmPassword = ctx.value().confirmPassword;

    return confirmPassword === password
      ? undefined
      : {
          kind: 'confirmationPassword',
        };
  });
validate(rootPath, (ctx) => {
    const password = ctx.value().password;
    const confirmPassword = ctx.value().confirmPassword;

    return confirmPassword === password
      ? undefined
      : {
          kind: 'confirmationPassword',
        };
  });
@for (error of registrationForm().errors(); track error.kind) {
   @if (error.kind === "confirmationPassword") {
      Ooppps, passwords do not match!
   }
}

Approach #1

validate(path.confirmPassword, (ctx) => {
    const password = ctx.valueOf(path.password);

    return ctx.value() === password
      ? undefined
      : {
          kind: 'confirmationPassword',
        };
  });
validate(path.confirmPassword, (ctx) => {
    const password = ctx.valueOf(path.password);

    return ctx.value() === password
      ? undefined
      : {
          kind: 'confirmationPassword',
        };
  });
@for (error of registrationForm.confirmPassword().errors();
      track error.kind) {
        
   @if (error.kind === "confirmationPassword") {
     Ooppps, passwords do not match!
   }
}

Approach #2

Approach #2

validateTree(path, (ctx) => {

    return ifTrue 
      	? undefined 
    	: [ customError() ];
});

Why Use It 🎯

What it is

Main Benefit

validateTree(path, ({ value, fieldOf }) => {
  return value().confirmPassword === value().password
    ? undefined
    : [
        {
          field: fieldOf(path.confirmPassword),
          kind: 'confirmationPassword',
        },
        {
          field: fieldOf(path.password),
          kind: 'confirmationPassword',
        },
      ];
});
required(path.country);
required(path.state, {
	when: (ctx) => ctx.valueOf(path.country) === 'US',
});
required(path.country);
required(path.state, {
	when: (ctx) => ctx.valueOf(path.country) === 'US',
});
@if (registrationForm.country().value() === "US") {
    <label>State</label>
    <input
      type="text"
      name="state"
      [formField]="registrationForm.state"
    />
}
required(path.country);
required(path.state, {
	when: (ctx) => ctx.valueOf(path.country) === 'US',
});
@if (registrationForm.country().value() === "US") {
    <label>State</label>
    <input
      type="text"
      name="state"
      [formField]="registrationForm.state"
    />
}
required(path.country);
required(path.state);
hidden(path.state, (ctx) => {
  return ctx.valueOf(path.country) !== 'US';
});
@if (!registrationForm.state().hidden()) {
    <label>State</label>
    <input
      type="text"
      name="state"
      [formField]="registrationForm.state"
    />
}

Schema

registrationForm = form<RegisterFormModel>(this.formModel, (path) => {
  required(path.email);
  required(path.password);
  required(path.confirmPassword);
  validateTree(path, ({ value, fieldOf }) => {
    return value().confirmPassword === value().password
      ? undefined
    : [
      {
        field: fieldOf(path.confirmPassword),
        kind: 'confirmationPassword',
      },
      {
        field: fieldOf(path.password),
        kind: 'confirmationPassword',
      },
    ];
  });
  required(path.country);
  hidden(path.state, (fieldCtx) => {
    return fieldCtx.valueOf(path.country) !== 'US';
  });
});
// -> registration-form.schema.ts

import { schema } from '@angular/forms/signals';

export const registrationSchema = schema((path) => {
  // Validators and logic goes here
});
export const registrationSchema = schema<RegisterFormModel>((path) => {
  required(path.email);
  required(path.password);
  required(path.confirmPassword);
  validateTree(path, ({ value, fieldOf }) => {
    return value().confirmPassword === value().password
      ? undefined
      : [
          {
            field: fieldOf(path.confirmPassword),
            kind: 'confirmationPassword',
          },
          {
            field: fieldOf(path.password),
            kind: 'confirmationPassword',
          },
        ];
  });
  required(path.country);
  hidden(path.state, (fieldCtx) => {
    return fieldCtx.valueOf(path.country) !== 'US';
  });
});
registrationForm = 
  form<RegisterFormModel>(this.formModel, (path) => {
      required(path.email);
      required(path.password);
      required(path.confirmPassword);
      validateTree(path, ({ value, fieldOf }) => {
        return value().confirmPassword === value().password
          ? undefined
        : [
          {
            field: fieldOf(path.confirmPassword),
            kind: 'confirmationPassword',
          },
          {
            field: fieldOf(path.password),
            kind: 'confirmationPassword',
          },
        ];
      });
      required(path.country);
      hidden(path.state, (fieldCtx) => {
        return fieldCtx.valueOf(path.country) !== 'US';
      });
    });
  registrationForm = form(this.formModel, registrationSchema);
// -> email.schema.ts

const emailSchema = schema<string>((path) => {
  required(path, { message: 'Email is required' });
  email(path, { message: 'Email is not valid' });
});
// -> email.schema.ts

const emailSchema = schema<string>((path) => {
  required(path, { message: 'Email is required' });
  email(path, { message: 'Email is not valid' });
});
// -> registration-form.schema.ts

const registrationSchema = schema<RegisterFormModel>((path) => {
  apply(path.email, emailSchema);
  required(path.password);
  required(path.confirmPassword);
  // rest of the validations
});
// -> email.schema.ts

const emailSchema = schema<string>((path) => {
  required(path, { message: 'Email is required' });
  email(path, { message: 'Email is not valid' });
});
// -> registration-form.schema.ts

const registrationSchema = schema<RegisterFormModel>((path) => {
  apply(path.email, emailSchema);
  applyEach(path.additionalEmails, emailSchema);
  required(path.password);
  required(path.confirmPassword);
  // rest of the validations
});

Login

Register

Create a

Contract

  formModel = signal<ContractFormModel>({
    title: '',
    projectOverview: {
      projectName: '',
      projectCode: '',
      startDate: new Date(),
      endDate: new Date(),
      projectLocation: '',
      projectDescription: '',
    },
    clientInformation: {
      clientName: '',
      clientContact: '',
      clientEmail: '',
      clientAddress: '',
      clientPhone: '',
    },
    contractorInformation: {
      contractorName: '',
      contractorLicenseNumber: '',
      contractorContact: '',
      contractorEmail: '',
      contractorAddress: '',
      contractorPhone: '',
    },
    scopeOfWork: {
      scopeDescription: '',
      deliverables: [],
      milestones: [],
    },
    termsConditions: {
      termsAccepted: false,
      confidentialityLevel: '',
      liabilityClauses: '',
      terminationClause: '',
      governingLaw: '',
      specialClauses: [],
    },
    signatory: {
      signatoryName: '',
      signatoryContractor: '',
      signatoryDate: new Date(),
      signatorySignature: '',
    },
    paymentDetails: {
      totalAmount: null,
      currency: '',
      paymentSchedule: '',
      invoiceFrequency: '',
    },
  });
<!-- contractor information START -->
  raw form fields goes here
<!-- contractor information END -->
  
<!-- client information START -->
  raw form fields goes here
<!-- client information END -->
  
<!-- scope of work START -->
  raw form fields goes here
<!-- scope of work END -->
  
<!-- project overview START -->
  raw form fields goes here
<!-- project overview END -->

<!-- terms and conditions START -->
  raw form fields goes here
<!-- terms and conditions END -->
<app-contractor-information 
	[formField]="myForm.contractorInformation" />
  
<app-client-information
  	[formField]="myForm.clientInformation"/>
  
<app-scope-of-work
  	[formField]="myForm.scopeOfWork" />
  
<app-project-overview
  	[formField]="myForm.projectOverview" />
  
<app-terms-conditions
  	[formField]="myForm.termsConditions" />
@Component({
  selector: 'app-scope-of-work',
  imports: [ Field, FieldTree ],
  templateUrl: './scope-of-work.component.html',
  styleUrl: './scope-of-work.component.scss',
})
export class ScopeOfWorkComponent {
  formField = input.required<FieldTree<ScopeOfWorkFormModel>>();

  addDeliverable() {
    this.formField()
      .deliverables()
      .value.update((current) => [
        ...current, { name: '', description: '' }
      ]);
  }
}
<textarea [formField]="formField().scopeDescription"></textarea>
<textarea [formField]="formField().scopeDescription"></textarea>
.
├── parent
│   └── parent.component.ts
├── children
│   ├── child-1
│   │   ├── child-1.component.ts
│   │   └── child-1.schema.ts
│   └── child-2
│       ├── child-2.component.ts
│       └── child-2.schema.ts
.
├── parent
│   └── parent.component.ts
├── children
│   ├── child-1
│   │   ├── child-1.component.ts
│   │   └── child-1.schema.ts
│   └── child-2
│       ├── child-2.component.ts
│       └── child-2.schema.ts
.
├── parent
│   └── parent.component.ts
├── children
│   ├── child-1
│   │   ├── child-1.component.ts
│   │   └── child-1.schema.ts
│   └── child-2
│       ├── child-2.component.ts
│       └── child-2.schema.ts
.
├── parent
│   └── parent.component.ts
├── children
│   ├── child-1
│   │   ├── child-1.component.ts
│   │   └── child-1.schema.ts
│   └── child-2
│       ├── child-2.component.ts
│       └── child-2.schema.ts
  parentForm = form(this.parentFormModel, (path) => {
    apply(path.childOne, childOneSchema);
    apply(path.childTwo, childTwoSchema);
    // rest of schemas goes here
  });
  parentForm = form(this.parentFormModel, (path) => {
    apply(path.childOne, childOneSchema);
    apply(path.childTwo, childTwoSchema);
    // rest of schemas goes here
  });

Custom Form Control

@Component({ ... })
export class StarRatingComponent {
  // Input property for the rating value
  rating = model<number>(0);
  
  stars = computed(() => {
    // Computed property to generate the stars array
  });

  // Click handler for star selection
  onStarClick(starIndex: number): void {
    this.rating.set(starIndex);
  }
}
<div class="star-rating">
  @for (star of stars(); track star.index) {
    <span [class.filled]="star.filled" 
          (click)="onStarClick(star.index)">
      ★
    </span>
  }
</div>
<app-star-rating [rating]="4" />
@Component({ ... })
export class StarRatingComponent {
  // Input property for the rating value
  rating = model<number>(0);
  
  stars = computed(() => {
    // Computed property to generate the stars array
  });

  // Click handler for star selection
  onStarClick(starIndex: number): void {
    this.rating.set(starIndex);
  }
}
@Component({ ... })
export class StarRatingComponent {
  // Input property for the rating value
  value = model<number>(0);
  
  stars = computed(() => {
    // Computed property to generate the stars array
  });

  // Click handler for star selection
  onStarClick(starIndex: number): void {
    this.value.set(starIndex);
  }
}
<!--app-star-rating [rating]="4" /-->
<app-star-rating [field]="path.rating" />
@Component({ ... })
export class StarRatingComponent {
  // Input property for the rating value
  value = model<number>(0);
  
  stars = computed(() => {
    // Computed property to generate the stars array
  });

  // Click handler for star selection
  onStarClick(starIndex: number): void {
    this.value.set(starIndex);
  }
}
@Component({ ... })
export class StarRatingComponent 
            implements FormValueControl<number> {
  // Input property for the rating value
  value = model<number>(0);
  
  stars = computed(() => {
    // Computed property to generate the stars array
  });

  // Click handler for star selection
  onStarClick(starIndex: number): void {
    this.value.set(starIndex);
  }
}

Thank you

Code. Teach. Community. Angular.

https://blog.profanis.me

/prodromouf

@prodromouf

Angular Signal Forms

By Fanis Prodromou

Angular Signal Forms

Angular Signal Forms: A deep introduction to Angular Signal Forms

  • 82