• 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

Scalable Architecture & Modern Reactivity

πŸ§‘β€πŸ’» Nx Foundation: Workspace & Features

90 mins

β˜• Short Break

15 mins

πŸ§‘β€πŸ’» Nx Architecture: Layers & Visualizing the GraphΒ 

90 mins

🍌 Short Break

15 mins

πŸ§‘β€πŸ’» Enforcing Rules & Introducing Signals

90 mins

πŸ§‘β€πŸ³Β Lunch Break

45 mins

πŸ§‘β€πŸ’» Advanced Reactivity: Resources, I/O & Forms

90 mins

🍌 Short Break

15 mins

πŸ™‹Β Wrap up and Q&A

30 mins

Nx Foundation & Architecture

Building Scalable "Moduliths" with Angular

In this lesson

  • The "Monolith" Problem
  • Why Nx?
  • What is "The Modulith"?
  • Explore the folder structure
  • Project Types
  • The Golden Rule: The Barrel File
  • Tooling: CLI & Nx Console
  • Hands-on Overview

The Goal:

GreenHaven Plant Shop

The Goal:

Products List

Product Details

Infinite Scroll

Search Functionality

tbd...

Show Time

The "Monolith" Problem

Reusing code between a Customer App and an Admin App is difficult.

Hard to Share

Changing one CSS file rebuilds everything

Slow Builds

Features accidentally depend on each other

Tight Coupling

Dump everything into src/app

The "Monolith" Problem

src/
└── app/
    β”œβ”€β”€ core/
    β”‚   β”œβ”€β”€ guards/
    β”‚   β”‚   β”œβ”€β”€ auth.guard.ts
    β”‚   β”‚   └── auth.guard.spec.ts
    β”‚   β”œβ”€β”€ header/
    β”‚   β”‚   β”œβ”€β”€ header.component.ts
    β”‚   β”‚   β”œβ”€β”€ header.component.html
    β”‚   β”‚   └── header.component.scss
    β”‚   β”œβ”€β”€ core.service.ts
    β”‚   └── core.service.spec.ts
    β”œβ”€β”€ features/
    β”‚   β”œβ”€β”€ feature-1/
    β”‚   β”‚   β”œβ”€β”€ feature-1.component.ts
    β”‚   β”‚   β”œβ”€β”€ feature-1.component.html
    β”‚   β”‚   β”œβ”€β”€ feature-1.routes.ts
    β”‚   β”‚   └── feature-1.service.ts
    β”‚   └── feature-2/
    β”‚       β”œβ”€β”€ feature-2.component.ts
    β”‚       β”œβ”€β”€ feature-2.component.html
    β”‚       β”œβ”€β”€ feature-2.routes.ts
    β”‚       └── feature-2.service.ts
    β”œβ”€β”€ shared/
    β”‚   β”œβ”€β”€ components/
    β”‚   β”‚   β”œβ”€β”€ button/
    β”‚   β”‚   β”‚   β”œβ”€β”€ button.component.ts
    β”‚   β”‚   β”‚   β”œβ”€β”€ button.component.html
    β”‚   β”‚   β”‚   └── button.component.scss
    β”‚   β”‚   └── input/
    β”‚   β”‚       β”œβ”€β”€ input.component.ts
    β”‚   β”‚       β”œβ”€β”€ input.component.html
    β”‚   β”‚       └── input.component.scss
    β”‚   β”œβ”€β”€ guards/
    β”‚   β”‚   β”œβ”€β”€ can-leave.guard.ts
    β”‚   β”‚   └── can-leave.guard.spec.ts
    β”‚   └── pipes/
    β”‚       β”œβ”€β”€ format-date.pipe.ts
    β”‚       └── format-date.pipe.spec.ts
    β”œβ”€β”€ app.component.ts
    β”œβ”€β”€ app.component.html
    β”œβ”€β”€ app.routes.ts
    β”œβ”€β”€ app.config.ts
    └── main.ts

dump everything into src/app

The "Monolith" Problem

src/
└── app/
    β”œβ”€β”€ core/
    β”‚   β”œβ”€β”€ guards/
    β”‚   β”‚   β”œβ”€β”€ auth.guard.ts
    β”‚   β”‚   └── auth.guard.spec.ts
    β”‚   β”œβ”€β”€ header/
    β”‚   β”‚   β”œβ”€β”€ header.component.ts
    β”‚   β”‚   β”œβ”€β”€ header.component.html
    β”‚   β”‚   └── header.component.scss
    β”‚   β”œβ”€β”€ core.service.ts
    β”‚   └── core.service.spec.ts
    β”œβ”€β”€ features/
    β”‚   β”œβ”€β”€ feature-1/
    β”‚   β”‚   β”œβ”€β”€ components/
    β”‚   β”‚   β”‚   β”œβ”€β”€ component-a.ts
    β”‚   β”‚   β”‚   └── component-b.ts
    β”‚   β”‚   β”œβ”€β”€ services/
    β”‚   β”‚   β”‚   β”œβ”€β”€ service-a.ts
    β”‚   β”‚   β”‚   └── service-b.ts
    β”‚   β”‚   β”œβ”€β”€ pipes/
    β”‚   β”‚   β”‚   β”œβ”€β”€ pipe-a.ts
    β”‚   β”‚   β”‚   └── pipe-b.ts
    β”‚   β”‚   β”œβ”€β”€ feature-1.component.ts
    β”‚   β”‚   β”œβ”€β”€ feature-1.component.html
    β”‚   β”‚   β”œβ”€β”€ feature-1.routes.ts
    β”‚   β”‚   └── feature-1.service.ts
    β”‚   └── feature-2/
    β”‚       β”œβ”€β”€ feature-2.component.ts
    β”‚       β”œβ”€β”€ feature-2.component.html
    β”‚       β”œβ”€β”€ feature-2.routes.ts
    β”‚       └── feature-2.service.ts
    β”œβ”€β”€ app.component.ts
    β”œβ”€β”€ app.component.html
    β”œβ”€β”€ app.routes.ts
    β”œβ”€β”€ app.config.ts
    └── main.ts

dump everything into src/app

The "Monolith" Problem

Features accidentally depend on each other

Tight Coupling

// src/app/core/logger.service.ts

import { FeatureAServiceService } 
	from '../features/feature-a.service'; // <-- Oops

@Injectable({ providedIn: 'root' })
export class LoggerService {
  private featureService = inject(FeatureAService)
}
// src/app/feature-1.service.ts
import { Feature2Service } 
	from '../feature-2.service'; // Dependency A -> B

@Injectable()
export class Feature1Service {
}

// src/app/feature-2.service.ts
import { Feature1Service } 
	from '../feature-1.service'; // Dependency B -> A

export class Feature2Service {
}

The "Monolith" Problem

Changing one CSS file rebuilds everything

Slow Builds

The "Monolith" Problem

Reusing codeΒ between a Customer App and an Admin App is difficult.

Hard to Share

Why Nx?

A smart build system Β (like Angular CLI on steroids)

Modern Tooling

Architectural Guardrails

Smart Rebuilds ("Affected")

Computation Caching

Why Nx?

A smart build system Β (like Angular CLI on steroids)

Computation Caching

Why Nx?

A smart build system Β (like Angular CLI on steroids)

Smart Rebuilds ("Affected")

Why Nx?

A smart build system Β (like Angular CLI on steroids)

Architectural Guardrails

Why Nx?

A smart build system Β (like Angular CLI on steroids)

Modern Tooling

The modulith

You don't need 50 apps to use Nx

Concept

- We build a Single ApplicationΒ (Monolith deployment)

- We structure it like MicroservicesΒ (Modular development)

The modulith

You don't need 50 apps to use Nx

Strategy

- Treat your libsΒ folder like internal npmΒ packages

- Strict boundariesΒ between features

- Clear publicΒ APIs

The modulith

You don't need 50 apps to use Nx

Why

Lack of organization slows down engineers

Folder Structure

/appsΒ 

- The shell

- Entry point that wiresΒ everything up

/libs

- business logic

- components

- UI components

πŸ“‚ workspace-root/
β”œβ”€β”€ πŸ“‚ apps/
β”‚   β”œβ”€β”€ πŸ“‚ customer-portal/  <-- App Shell
└── πŸ“‚ libs/
    β”œβ”€β”€ πŸ“‚ data-access/      <-- Shared Logic
    β”œβ”€β”€ πŸ“‚ ui-components/    <-- Shared UI
    └── πŸ“‚ feature-orders/   <-- Feature Logic

Folder Structure

/appsΒ 

20%

/libs

πŸ“‚ workspace-root/
β”œβ”€β”€ πŸ“‚ apps/
β”‚   β”œβ”€β”€ πŸ“‚ customer-portal/  <-- App Shell
└── πŸ“‚ libs/
    β”œβ”€β”€ πŸ“‚ data-access/      <-- Shared Logic
    β”œβ”€β”€ πŸ“‚ ui-components/    <-- Shared UI
    └── πŸ“‚ feature-orders/   <-- Feature Logic

80%

Project Types

Utility

- Like Date Formatters

Data-Access

- Like our Product API Service

UI

- Like our Product Card

Feature

- Like our Catalogue Page

The structure is the solution

The Barrel File

Only Export What You Mean To Share

The Problem

- developers can import any file deep within another library

import { InternalHelper } 
	from 'libs/data-access/src/lib/internal-folder/helper.service'

The Barrel File

Only Export What You Mean To Share

The Solution

- Every Nx library has a barrel file, typically located at libs/my-lib/src/index.ts

- Anything you don't export from this file is considered "private"Β to that library.

The Barrel File

Only Export What You Mean To Share

Example

πŸ“‚ libs/data-access/
β”œβ”€β”€ πŸ“‚ src/lib/
β”‚   β”œβ”€β”€ internal-helper.service.ts  <-- Private file
β”‚   └── public-product.service.ts   <-- Public file
└── index.ts                        <-- Public API
// index.ts content:
export * from './src/lib/public-product.service';

// We do NOT export internal-helper.service.ts here!

The How

NX Tooling

Powerful CLI

# Create a feature library
npx nx g @nx/angular:library --name=feature-home 
							 --directory=libs/feature-home
                             
# Create a UI library
npx nx g @nx/angular:library --name=ui-header 
							 --directory=libs/shared/ui/header

Create a workspace

> npx create-nx-workspace@latest

Β 

Create a Library
> nx generate @nx/angular:library [name]

Generate a Component

> nx generate @nx/angular:component [name] --project=[project-name]

Basic NX Commands

CLI

more: https://nx.dev/docs/technologies/angular/guides/nx-and-angular

The How

NX Tooling

NX Console

Hands-on

- Create the Nx Workspace

- Install the Nx ConsoleΒ plugin in VS Code.

- Generate the feature-catalogue using NX CLI

- Generate the feature-home using the NX Console

- Setup the router

Hands-on

Create the NX Workspace

npx create-nx-workspace@latest --preset angular-monorepo

Hint

Hands-on

βœ”Β Where would you like to create your workspace?Β Β·Β greenHeaven

βœ”Β Application nameΒ Β·Β greenHeaven

βœ”Β Which bundler would you like to use?Β Β·Β esbuild

βœ”Β Default stylesheet formatΒ Β·Β scss

βœ”Β Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)?Β Β·Β No

βœ”Β Which unit test runner would you like to use?Β Β·Β jest

βœ”Β Test runner to use for end to end (E2E) testsΒ Β·Β none

βœ”Β Which CI provider would you like to use?Β Β·Β skip

βœ”Β Would you like remote caching to make your build faster?Β Β·Β skip

Create the NX Workspace

Hands-on

Install the Nx Console plugin in VS Code

Hands-on

Setup & Exploration

Explore the file system

and identify the:

- apps/greenHeaven/project.json

- nx.json

Hands-on

Create the Products-List Feature using the NX CLI

npx nx g @nx/angular:library --name=feature-catalogue 
							 --directory=libs/feature-catalogue

Hint

Hands-on

Create the Home Feature using the NX Console

Hint

Hands-on

Verify the barrel files and the project.json

- Open the index.ts of both libraries and verify the components are exported.

- Open the project.json of each library and identify the name of the library and the projectType

Hands-on

Create the Routing

// apps/greenHeaven/src/app/app.routes.ts

export const appRoutes: Route[] = [
  {
    path: '',
    loadComponent: () =>
      import('@green-heaven/feature-home').then((m) => m.FeatureHome),
    pathMatch: 'full',
  },
  {
    path: 'catalogue',
    loadComponent: () =>
      import('@green-heaven/feature-catalogue').then((m) => m.FeatureCatalogue),
  },
];

Hands-on

Create the Routing

- Update app.component.htmlΒ  to include a simple navbar (just text links for now)

- npx nx serve greenHeaven

- Navigate between "Home" and "Catalogue"

Hands-on

Create the Routing

Ensure the Feature is Rendered

Nx Architecture &

The Intelligent Graph

Types, Scopes, and the Shell Pattern

In this lesson

  • Project Types (The Building Blocks)
  • Grouping by Scope (The Domain)
  • The Shell Library Pattern
  • Naming Conventions
  • Metadata & Tags (project.json)
  • The Intelligent Graph

Project Types

State (Signals), NGXS/NGRX, HTTP Services

Data-Access (type:data-access)

Dumb components. Pure presentation

UI (type:ui)

Pure functions, helpers, validators

Utility (type:util)

The building blocks

The "Smart" logic and routing.

Feature (type:feature)

Feature Libraries

type:feature

The role

Smart components that orchestrate the UI components.

Contains

Rules

It’s the only library type allowed to have routes.

Page Layouts

Services / Facades

Domain-specific logic

GreenHeaven example

feature-catalogue

What it does

Orchestrates the entire page

UI Libraries

type:ui

The role

"Dumb" components focused on pure presentation.

Contains

Cards

Business UI Components

Buttons

GreenHeaven example

ui-product-card

Rules

It just receives input(s) and emits output(s)

What it does

A UI unit of the entire application

Data-Access Libraries

type:data-access

The role

Managing State and API communication.

Contains

HTTP Services

NGXS/NGRX

Angular Signals/State

GreenHeaven example

catalogue-data-access

What it does

Communicates with the HTTP API

Rules

It has no UI components

Utility Libraries

type:util

The role

Low-level, pure helper functions

Contains

What it does

Increases the reusability

Rules

Has zero dependencies on other library types.

Date formatters

Math helpers

Custom validators

GreenHeaven example

??

Scaling Up

Organizing by Domain

As the app grows, a flat libs/Β folder with 100+ librariesΒ becomes hard to find code and hard to see ownership

The problem

Group libraries by Scope (Domain)

The solution

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

The Shell Library

The Entry Point for a domain

If the App knows about "List"Β and "Details", the App is too smart

The problem

The AppΒ knows the Shell.

The ShellΒ sets up the child routes for the domain

The solution

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

Shell

Shell

Shell

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

Shell

Shell

Shell

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

Shell

Shell

Shell

The Shell Library

It orchestrates how the work is started and displayed.

A specific sidebar, header, or footer that only appears within this feature module

Wrapper Layouts

Provides NGXS/NgRx state slices for the entire domain.

State Bootstrapping

Handle the auth guards and route resolvers

Guards and Resolvers

Defines the top-level routes that the main app loads.Β 

Routing & Lazy Loading

App

Util

Data

UI

Feat

UI

UI

Feat

Data

UI

UI

Util

UI

Util

Feat

Feat

Feat

Feat

Feat

Scope A

Scope B

Scope C

Shell

Shell

Shell

App

Scope A

Scope B

Scope C

Feat

UI

Data

Util

UI

Feat

Util

Util

Feat

UI

Data

App

UI

Data

Util

Feat

Feat

UI

Util

Util

UI

Data

Feat

Scope A

Scope B

Scope C

Metadata and Tags

How does Nx know that feature-list is a "Feature"? We tell it.

tag:

scope:

Scaling Up

Organizing by Domain

Within a single domain, we apply our Taxonomy to keep the internal structure clean

libs/
└── catalogue/                      <-- The Domain Boundary
    β”œβ”€β”€ data-access/                🧠 State & API 
    β”œβ”€β”€ feature-catalogue-details/  πŸ“„ Catalogue Details Page 
    β”œβ”€β”€ feature-catalogue-list/   	πŸ“„ Catalogue List Page 
    β”œβ”€β”€ feature-shell/     			🐚 Entry Point & Internal Routing
    └── types/     					🐚 Domain specific types

The Shell Library

The Entry Point for a domain

// src/app/routes.ts

export const appRoutes: Route[] = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'catalogue',
    loadComponent: () =>
      import('@workshop/catalogue-feature-catalogue-list').then(
        (m) => m.CatalogueComponent
      ),
  },
  {
    path: 'catalogue/:id',
    loadComponent: () =>
      import('@workshop/catalogue-feature-catalogue-details').then(
        (m) => m.CatalogueDetailsComponent
      ),
  },
  { path: '**', redirectTo: '' },
];

The Shell Library

The Entry Point for a domain

// src/app/routes.ts

export const appRoutes: Route[] = [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  {
    path: 'catalogue',
    loadChildren: () =>
      import('@workshop/catalogue-feature-shell').then(
        (m) => m.catalogueRoutes
      ),
  },
  { path: '**', redirectTo: '' },
];

Naming Convention

Naming consistency is key for tools and humans

  • 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

Scalable Architecture & Modern Reactivity

By Fanis Prodromou

Scalable Architecture & Modern Reactivity

Angular Signals: A Look Under the Hood and Beyond

  • 8