Fanis Prodromou
I am a Senior Software Engineer with a passion for Front End development with Angular. I have developed vast experience in code quality, application architecture, and application performance.
Fanis Prodromou
/prodromouf
https://blog.profanis.me
@prodromouf
π§βπ» 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
Building Scalable "Moduliths" with Angular
Products List
Product Details
Infinite Scroll
Search Functionality
tbd...
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
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.tsdump everything into src/app
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.tsdump everything into src/app
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 {
}Changing one CSS file rebuilds everything
Slow Builds
Reusing codeΒ between a Customer App and an Admin App is difficult.
Hard to Share
A smart build system Β (like Angular CLI on steroids)
Modern Tooling
Architectural Guardrails
Smart Rebuilds ("Affected")
Computation Caching
A smart build system Β (like Angular CLI on steroids)
Computation Caching
A smart build system Β (like Angular CLI on steroids)
Smart Rebuilds ("Affected")
A smart build system Β (like Angular CLI on steroids)
Architectural Guardrails
A smart build system Β (like Angular CLI on steroids)
Modern Tooling
You don't need 50 apps to use Nx
Concept
- We build a Single ApplicationΒ (Monolith deployment)
- We structure it like MicroservicesΒ (Modular development)
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
You don't need 50 apps to use Nx
Why
Lack of organization slows down engineers
/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/appsΒ
/libs
π workspace-root/
βββ π apps/
β βββ π customer-portal/ <-- App Shell
βββ π libs/
βββ π data-access/ <-- Shared Logic
βββ π ui-components/ <-- Shared UI
βββ π feature-orders/ <-- Feature LogicUtility
- 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
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'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.
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!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/headerCreate 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]
CLI
more: https://nx.dev/docs/technologies/angular/guides/nx-and-angular
NX Tooling
NX Console
- 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
Create the NX Workspace
npx create-nx-workspace@latest --preset angular-monorepoHint
βΒ 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
Install the Nx Console plugin in VS Code
Setup & Exploration
Explore the file system
and identify the:
- apps/greenHeaven/project.json
- nx.json
Create the Products-List Feature using the NX CLI
npx nx g @nx/angular:library --name=feature-catalogue
--directory=libs/feature-catalogueHint
Create the Home Feature using the NX Console
Hint
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
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),
},
];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"
Create the Routing
Ensure the Feature is Rendered
Types, Scopes, and the Shell Pattern
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)
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
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
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
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
??
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 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
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
How does Nx know that feature-list is a "Feature"? We tell it.
tag:
scope:
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 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 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 consistency is key for tools and humans
ng new signal-forms-app --nextLogin
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
By simply using the dot (.) notation
loginForm
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()
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
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
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
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?
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
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.hasErrorthis.anotherService.get().pipe(
// forgot to use the custom operator
)
// loadingState.isLoading
// loadingState.hasErrorsignal<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)
)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)
}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
By Fanis Prodromou
Angular Signals: A Look Under the Hood and Beyond
I am a Senior Software Engineer with a passion for Front End development with Angular. I have developed vast experience in code quality, application architecture, and application performance.