• Google Developer Expert for Angular
  • Senior Angular Developer  @ ASI
  • Co-organizer of Angular Athens Meetup
  • Angular Content Creator

Fanis Prodromou

/prodromouf

https://blog.profanis.me

@prodromouf

Introduction

to

Signal Forms

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

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 { Field, form } from '@angular/forms/signals';

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

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

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

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

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

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

Validators

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

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

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

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

@Component({
  selector: 'app-login',
  imports: [Field],
  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
    [field]="loginForm.email"
    type="email"
    placeholder="Enter your email"
  />
  @if (loginForm.email().touched() && loginForm.email().invalid()) {
    <p>The email is invalid.</p>
  }

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

  <button [disabled]="loginForm().invalid()">Login</button>
</form>
<form>
  <input
    [field]="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
    [field]="loginForm.password"
    type="password"
    placeholder="Enter your password"
  />
  <!-- same as above -->

  <button [disabled]="loginForm().invalid()">Login</button>
</form>
<form>
  <input
    [field]="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 { Field, email, 
        form, required  } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [Field],
  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
    [field]="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 customError({
      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 customError({
        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
      : customError({
          kind: 'confirmationPassword',
        });
  });
validate(rootPath, (ctx) => {
    const password = ctx.value().password;
    const confirmPassword = ctx.value().confirmPassword;

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

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

    return confirmPassword === password
      ? undefined
      : customError({
          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
      : customError({
          kind: 'confirmationPassword',
        });
  });
validate(path.confirmPassword, (ctx) => {
    const password = ctx.valueOf(path.password);

    return ctx.value() === password
      ? undefined
      : customError({
          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
    : [
        customError({
          field: fieldOf(path.confirmPassword),
          kind: 'confirmationPassword',
        }),
        customError({
          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"
      [field]="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"
      [field]="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"
      [field]="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
    : [
      customError({
        field: fieldOf(path.confirmPassword),
        kind: 'confirmationPassword',
      }),
      customError({
        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
      : [
          customError({
            field: fieldOf(path.confirmPassword),
            kind: 'confirmationPassword',
          }),
          customError({
            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
        : [
          customError({
            field: fieldOf(path.confirmPassword),
            kind: 'confirmationPassword',
          }),
          customError({
            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 
	[field]="myForm.contractorInformation" />
  
<app-client-information
  	[field]="myForm.clientInformation"/>
  
<app-scope-of-work
  	[field]="myForm.scopeOfWork" />
  
<app-project-overview
  	[field]="myForm.projectOverview" />
  
<app-terms-conditions
  	[field]="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 {
  field = input.required<FieldTree<ScopeOfWorkFormModel>>();

  addDeliverable() {
    this.field()
      .deliverables()
      .value.update((current) => [
        ...current, { name: '', description: '' }
      ]);
  }
}
<textarea [field]="field().scopeDescription"></textarea>
<textarea [field]="field().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 !!

/prodromouf

https://blog.profanis.me

@prodromouf

Code Shots With Profanis

CVA

CVA

Control Value Accessor

CVA

Control Value Accessor

DevEx

Performance

UX

UX

DevEx

Performance

signal inputs

model input

signal queries

effect

afterRenderEffect

computed

linkedSignal

resource API

UX

DevEx

Performance

more fine grained CD

glitch free rendering

UX

DevEx

Performance

more fine grained CD

fewer CD cycles

Zoneless

- smart variables that notify anyone who's interested when their value changes.

What are Signals?

- Traditional change detection checks everything. Signals allow tracks specific values

- Traditional Change Detection can be slow on complex applications

Why Signals?

- zone.js is great but triggers the CD multiple times 

Zone.js

User Interaction

dom event (click)

zone.js

(Angular Zone)

Change Detection

UI Update

Angular checks the entire component tree when the micro-task queue is empty

Default + Observable

OnPush + Observable

OnPush + Signals

OnPush + Observable

OnPush + Signals

OnPush + Signals

Default + Observable

OnPush + Observable

OnPush + Observable

Dirty

Dirty

Dirty

The Solution

Let's Signal

Consumers

Producers

Observers

Subject

Consumers

Producers

Effect

Template

counter

Consumers

Producers

Effect

Template

counter

Consumers

Producers

counter = signal<number>(0);

Producer

counter = signal<number>(0);

Returns a WritableSignal

Producer

counter = signal<number>(0);

Define the type

Producer

counter = signal<number>(0);

Default value

Producer

counter = signal<number>(0);

Producer

<div>
  {{ counter() }}
</div>

Consumer

Template Context

Consumer

effect(() => {
  console.log(this.counter());
});

Context

Consumer

Consumer

const evenOrOdd = 
      computed(() => counter() % 2 === 0 ? 'even' : 'odd');

Consumer & Producer

Producer

Consumer

The Graph

const counter = signal(0);
const counter = signal(0);
<div> {{ counter() }} </div>
<div> {{ counter() }} </div>
<div> {{ counter() }} </div>
<div> {{ counter() }} </div>
const evenOrOdd = 
	computed(() => counter() % 2 === 0 ? 'even' : 'odd');
<div> {{ counter() }} </div>
const evenOrOdd = 
	computed(() => counter() % 2 === 0 ? 'even' : 'odd');
<div> {{ counter() }} </div>
const evenOrOdd = 
	computed(() => counter() % 2 === 0 ? 'even' : 'odd');

"Since Angular knows how the data flows, can have a more fine-grained change detection"

Signals

Signals

Traversal

Traversal

Traversal

Pull - Push

const isValid = signal(true);
const username = signal('profanis');

effect(() => {
  if (isValid() === true) {
    console.log(username());
  }
});

// Update signal values
isValid.set(false);
username.set('profanis2');
effect(() => {
  if (isValid() === true) {
    console.log(username());
  }
});

// Update signal values
isValid.set(false);
username.set('profanis2');

Consumers

Producers

isValid

effect

username

Push (notification)

effect(() => {
  if (isValid() === true) {
    console.log(username());
  }
});

// Update signal values
isValid.set(false);
username.set('profanis2');

Consumers

Producers

isValid

username

Push (notification)

effect

effect(() => {
  if (isValid() === true) {
    console.log(username());
  }
});

// Update signal values
isValid.set(false);
username.set('profanis2');

Consumers

Producers

isValid

username

Pull (value)

effect

effect(() => {
  console.log(`${isValid()} - ${username()}`);
});

isValid.set(false);
username.set('profanis2');

Consumers

Producers

isValid

username

Push (notification)

effect

Consumers

Producers

isValid

username

Push (notification)

effect(() => {
  console.log(`${isValid()} - ${username()}`);
});

isValid.set(false);
username.set('profanis2');

effect

Consumers

Producers

isValid

username

Pull (value)

Pull (value)

effect(() => {
  console.log(`${isValid()} - ${username()}`);
});

isValid.set(false);
username.set('profanis2');

effect

effect

isValid

username

Q: Can I have the same magic 🪄 in HTTP calls?

? One
? Two
? Three

? One

makeHttpCall() {
  this.isLoading = true;
  
  this.postsService.get(userId).pipe(
  	finalize(() =>  this.isLoading = false)
  )
}
  return (source: Observable<any>): Observable<any> => {
    return new Observable((observer) => {
      const subscription = source.subscribe({
        next: (value) => {
          // is loading
        },
        error: (error) => {
          // is not loading
        },
        complete: () => {
          // is not loading
        },
      });

      return () => {
        // clean up
      };
    });
  };
this.postsService.get().pipe(
  customRxJsOperator(loadingState)
)

// loadingState.isLoading

? Two

makeHttpCall() {
  this.isLoading = true;
  this.hasError = false;
  
  this.postsService.get(userId).pipe(
    catchError((error) => {
      this.hasError = true;
      return of(null);
    }),
    finalize(() => this.isLoading = false)
  )
}
  return (source: Observable<any>): Observable<any> => {
    return new Observable((observer) => {
      const subscription = source.subscribe({
        next: (value) => {
          // is loading
          // no error
        },
        error: (error) => {
          // is not loading
          // has error
        },
        complete: () => {
          // is not loading
          // no error
        },
      });

      return () => {
        // clean up
      };
    });
  };
this.postsService.get().pipe(
  customRxJsOperator(loadingState)
)

// loadingState.isLoading
// loadingState.hasError
this.anotherService.get().pipe(
    // forgot to use the custom operator
)

// loadingState.isLoading
// loadingState.hasError

? Three

signal<string>

signal<string>

signal<string>

cancel

cancel

How about effect?

time - 0

time - 1

request

request

100ms

500ms

time - 0

time - 1

request

request

100ms

500ms

time - 0

time - 1

request

request

100ms

500ms

time - 0

time - 1

request

request

100ms

500ms

Sync

Async

At some point we will have the data

At some point we will have the data

We will always have data

http

isLoading

error

data

http

isLoading()

error()

data()

httpResource

httpResource makes a reactive HTTP request and exposes the request status and response value

httpResource(
	?, 
	?
)
httpResource(
	string | object | function , 
	?
)
httpResource(
	string | object | function , 
	options
)
// String
httpResource(`https://api.com/${signalValue()}`)

dependency

// Object
httpResource(
	{
		url: `https://api.com/${signalValue()}`,
		method: 'GET',
		params: { type: `${queryParamSignalValue()}` }
	}
)

dependency

// Object
httpResource(
	{
		url: `https://api.com/${signalValue()}`,
		method: 'GET',
		params: { type: `${queryParamSignalValue()}` }
	}
)

dependency

// Object
httpResource(
	{
		url: `https://api.com/${signalValue()}`,
		method: 'GET',
		params: { type: `${queryParamSignalValue()}` }
	}
)

Http verb

// Function
httpResource(() => `https://api.com/${signalValue()}`)
// Function
httpResource(() => 
             signalValue() ? 
             `https://api.com/${signalValue()}` : 
             undefined
)
// String with Options
httpResource(`https://api.com/${signalValue()}`, {
	defaultValue: {},
	parse: (response) => zodSchema.parse(response),
})
// String
resource = httpResource(`https://api.com/${signalValue()}`)
@if (resource.isLoading()) {
    <div>Loading...</div>
}

@if (resource.error()) {
    <div>Oops...</div>
}

@if (resource.value()) {
    <div>{{ resource.value() }}</div>
}
// String
resource = httpResource(`https://api.com/${signalValue()}`)
derivedState = computed(
  () => resource.value().map(it => it.title)
)

? Loading
 

? Loading
? Error
 

? Loading
? Error
? Data

Q: How can I cancel the previous http calls?

A: The httpResource behaves similar to switchMap

Q: How can I debounce the calls?

A: We should debounce the value and not the calls

DevEx

Performance

UX

linkedSignal

listOfItems = signal(['item1', 'item2', 'item3']);

countOfItems = linkedSignal(() => this.listOfItems().length);
countOfItems.set(0)
listOfItems = signal(['item1', 'item2', 'item3']);

countOfItems = computed(() => this.listOfItems().length);
countOfItems.set(0)
listOfItems = signal(['item1', 'item2', 'item3']);

countOfItems = linkedSignal(() => this.listOfItems().length);
countOfItems.set(0)
listOfItems = signal(['item1', 'item2', 'item3']);

countOfItems = linkedSignal({
    source: this.listOfItems,
    computation: (items) => items.length,
});
countOfItems.set(0)
listOfItems = signal(['item1', 'item2', 'item3']);

countOfItems = linkedSignal({
    source: this.listOfItems,
    computation: (items) => items.length, // 3
});
countOfItems.set(0)
listOfItems = signal(['item1', 'item2', 'item3', 'item4']);

countOfItems = linkedSignal({
    source: this.listOfItems,
    computation: (items) => items.length, // 4
});
countOfItems.set(0)
listOfItems = signal([ 
  { name: 'item 1' }, 
  { name: 'item 2' }, 
  { name: 'item 3' } 
]);


// keeps the selected item
selectedItem = listOfItems[0];
listOfItems = signal([ 
  { name: 'item 1' }, 
  { name: 'item 2' }, 
  { name: 'item 3' } 
]);



// an HTTP call is happening
http.get().subscribe(data => 
	this.listOfItems.set([...data])
)
listOfItems = signal([ 
  { name: 'item 1' }, 
  { name: 'item 2' }, 
  { name: 'item 3' } 
]);





selectedItem = signal<Item | null>(null);
listOfItems = signal([ 
  { name: 'item 1' }, 
  { name: 'item 2' }, 
  { name: 'item 3' } 
]);




selectedItem = linkedSignal({
  source: this.listOfItems,
  computation: (items, previous) =>
    items.find((item) => item.name === previous?.value.name),
});

DevEx

searchInput = new FormControl('');

searchInputDebounced$ = this.searchInput.valueChanges.pipe(
  debounceTime(500)
);

searchInputDebounced = toSignal(this.searchInputDebounced$);

optionsResource = httpResource<RecipeResponse>(
  `https://api.com?q=${this.searchInputDebounced()}`
);

Signal APIs

signal

inputs

new output

model input

signal queries

signal

inputs

model input

signal queries

new output

model input

signal queries

new output

signal

inputs

model input

signal queries

new output

signal

inputs

model input

signal queries

new output

signal

inputs

signal

inputs

@Component({...})
export class MyComponent {
  @Input() isChecked = false;
}
@Component({...})
export class MyComponent {
  isChecked = input(false);
}

read-only signal

export interface UserModel {
  name: string;
  age: number;
  
  /*Social*/
  address: string;
  twitter: string;
  linkedin: string;
  github: string;
  instagram: string;
  facebook: string;
  website: string;
  email: string;
}
@Component({...})
export class MyComponent implements OnChanges {
  @Input({ required: true }) user!: UserModel;
  userSocials: string[] = [];

  ngOnChanges(changes: SimpleChanges): void {
    if (changes.user) {
      const { name, age, ...userSocials } = this.user;
      this.userSocials = Object.values(userSocials);
    }
  }
}
@Component({...})
export class MyComponent {
  user = input.required<UserModel>();
  userSocials = computed(() => {
    const { name, age, ...userSocials } = this.user();
    return Object.values(userSocials);
  });
}

model input

@Component({...})
export class ChildComponent {
  @Input({ required: true }) name!: string;
  @Output() nameChange = new EventEmitter<string>();
}
@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: `
    <app-child 	[name]="username" 
				(nameChange)="changeHandler($event)" />
  `,
})
export class ParentComponent { 
   username = 'profanis';
}
@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: `
    <app-child 	[name]="username" 
				(nameChange)="changeHandler($event)" />
  `,
})
export class ParentComponent { 
   username = 'profanis';
}
@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: ` <app-child [(name)]="username" /> `,
})
export class ParentComponent {
  username = 'profanis';
}
@Component({...})
export class ChildComponent {
  // @Input({ required: true }) name!: string;
  // @Output() nameChange = new EventEmitter<string>();
  name = model<string>();
}
@Component({...})
export class ChildComponent {
  // @Input({ required: true }) name!: string;
  // @Output() nameChange = new EventEmitter<string>();
  name = model.required<string>();
}
@Component({...})
export class ChildComponent {
  name = model<string>(); // writable signal

  addTitle() {
    this.name.update((name) => `Mr. ${name}`);
  }
}
@Component({...})
export class ChildComponent {
  name = model<string>(); // writable signal

  titleExists = computed(() => this.name().startsWith('Mr.'));
}
@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: ` <app-child [(name)]="username" /> `,
})
export class ParentComponent {
  username = 'profanis';
}

new output

@Component({...})
export class ChildComponent {
  name = input.required<string>();
  @Output() nameChange = new EventEmitter<string>();
}
@Component({...})
export class ChildComponent {
  name = input.required<string>();
  nameChange = output<string>()
}
@Component({...})
export class ChildComponent {
  @Output() formIsValid = this.form.statusChanges.pipe(
     map((status) => status === 'VALID'),
  );
}
import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({...})
export class ChildComponent {
  formIsValid = outputFromObservable(
    this.form.statusChanges.pipe(
            map((status) => status === 'VALID'))
  );
}

Optional RxJS

import { outputToObservable } from '@angular/core/rxjs-interop';

@Component({...})
export class ChildComponent {
  name = input.required<string>();
  nameChange = output<string>()
  nameChange$ = outputToObservable(nameChange)
}

What 

& How

Consumers

Producers

Effect

Template

counter

Template

Effect

counter.set(1);

Produces new value

Consumers

Producers

Effect

Template

counter.set(     )

Template

Effect

1

Consumers

Producers

Effect

Template

counter.set(1)

Template

Effect

1

Consumers

Producers

Effect

Template

counter.set(1)

Template

Effect

1

Consumers

Producers

Effect

Template

Template

Effect

1

counter.set(1)

1

Consumers

Producers

Effect

Template

Template

Effect

1

counter.set(1)

1

Consumers

Producers

Effect

Template

Template

Effect

1

counter.set(1)

1

Consumers

Producers

Effect

Template

Template

Effect

counter.set(1)

Counter

Counter

Consumers

Producers

Effect

Template

Template

Effect

counter.set(1)

Counter

Counter

Edge

ref_con

ref_prod

Consumers

Producers

Effect

Template

Template

Effect

counter.set(1)

Counter

Counter

Edge

ref_con

ref_prod

Consumers

Producers

Effect

Template

Template

Effect

counter.set(1)

Counter

Counter

Edge

ref_con

ref_prod

Consumers

Producers

Effect

Template

Template

Effect

counter.set(1)

Counter

Counter

Edge

ref_con

ref_prod

Consumers

Producers

Effect

Template

Template

Effect

counter.set(1)

Counter

Counter

Edge

ref_con

ref_prod

Consumers

Producers

counter

const counter = signal(0);

Consumers

Producers

counter

const counter = signal(0);

effect(() => console.log(counter());

Effect

Consumers

Producers

counter

const counter = signal(0);

effect(() => console.log(counter());

Counter

Effect

Consumers

Producers

counter

const counter = signal(0);

effect(() => console.log(counter());

Counter

Effect

Effect

Consumers

Producers

counter.set(     )

const counter = signal(0);

effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

1

Consumers

Producers

counter

const counter = signal(0);

effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

1

Consumers

Producers

counter

const counter = signal(0);

effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

1

Consumers

Producers

counter

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

computed

Consumers

Producers

counter

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter.set(     )

1

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

1

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter());

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

1

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

computed

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

computed

Effect

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

computed

Effect

Consumers

Producers

const counter = signal(0);
const evenOrOdd = computed(() => counter() % 2 === 0 ? 'even' : 'odd');
effect(() => console.log(counter() + ' is ' + evenOrOdd()));

counter.set(1);

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

computed

Effect

Counter

Effect

Effect

Computed

Counter

Computed

counter

1

computed

Effect

console.log("1 is even")
console.log("1 is odd")
console.log("1 is even")
console.log("1 is odd")

Counter

Log

Log

Computed

Counter

Computed

counter

10

computed

Log

GLITCH

Angular Signal Forms

By Fanis Prodromou

Angular Signal Forms

Angular Signals: A Look Under the Hood and Beyond

  • 37