Angular How-To Guides: practical integrations, state management and advanced Reactive Forms
Step-by-step guides are among the most useful content for developers because they solve real problems: installing a library, configuring a project correctly, choosing a state management strategy, building complex forms and preventing hard-to-find bugs.
In this article we will follow a practical path to work better with modern Angular: integrating Tailwind CSS, using Chart.js, animations, state management with Signals, NgRx and Akita, and building advanced reactive forms with synchronous and asynchronous validation.
1. Angular project prerequisites
Before integrating libraries or implementing complex architectures, it pays to start from a clean base. We will use a modern Angular project, preferably with standalone components, Reactive Forms and TypeScript.
Creating the project
npm install -g @angular/cli
ng new angular-how-to-guide
cd angular-how-to-guide
ng serve
If you are working on an existing project, make sure you have at least:
- Angular updated to a recent version.
- Node.js in a version compatible with Angular.
- TypeScript configured correctly.
- A clear structure for components, services, models and store.
Recommended structure
src/
app/
core/
services/
guards/
interceptors/
shared/
components/
pipes/
validators/
features/
dashboard/
products/
profile/
state/
signals/
ngrx/
app.config.ts
app.routes.ts
A tidy structure helps above all as the project grows. Separating core, shared, features and state helps avoid confusing dependencies and oversized components.
2. How to integrate Tailwind CSS in Angular
Tailwind CSS is a utility-first library that lets you build modern interfaces without writing large amounts of custom CSS. In Angular it is very useful for dashboards, admin panels, landing pages and reusable components.
Installation
npm install tailwindcss @tailwindcss/postcss postcss --save-dev
Create or update the PostCSS configuration file.
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
Then import Tailwind in the global stylesheet, for example src/styles.css.
@import "tailwindcss";
Example of an Angular component with Tailwind
import { Component } from '@angular/core';
@Component({
selector: 'app-pricing-card',
standalone: true,
template: `
<section class="mx-auto max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<p class="text-sm font-medium uppercase tracking-wide text-indigo-600">Piano Pro</p>
<h2 class="mt-2 text-3xl font-bold text-slate-900">€29/mese</h2>
<p class="mt-3 text-slate-600">
Ideale per team che vogliono creare applicazioni Angular più velocemente.
</p>
<ul class="mt-6 space-y-2 text-sm text-slate-700">
<li>Componenti riutilizzabili</li>
<li>Dashboard responsive</li>
<li>Supporto a form complessi</li>
</ul>
<button class="mt-6 w-full rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white hover:bg-indigo-700">
Inizia ora
</button>
</section>
`
})
export class PricingCardComponent {}
Best practices with Tailwind in Angular
- Avoid huge templates full of repeated classes.
- Extract reusable components when a piece of UI repeats.
- Use conditional classes with
[class.nome-classe]or[ngClass]. - Define consistent design tokens for colours, spacing and typography.
- Don't use Tailwind as an excuse to ignore accessibility.
Example with conditional classes
<button
type="button"
class="rounded-lg px-4 py-2 font-semibold transition"
[class.bg-green-600]="isActive"
[class.text-white]="isActive"
[class.bg-slate-200]="!isActive"
[class.text-slate-700]="!isActive">
Stato elemento
</button>
This technique is useful for tabs, active buttons, filters, selected items and validation states.
3. How to integrate Chart.js in Angular
Chart.js is a widely used library for line, bar, radar and doughnut charts and statistics dashboards. In Angular you can use it directly or through dedicated wrappers. Here we look at a direct, simple and controllable integration.
Installation
npm install chart.js
Creating the chart component
ng generate component features/dashboard/revenue-chart --standalone
Component TypeScript code
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild
} from '@angular/core';
import {
Chart,
ChartConfiguration,
registerables
} from 'chart.js';
Chart.register(...registerables);
@Component({
selector: 'app-revenue-chart',
standalone: true,
template: `
<div class="h-80 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-slate-900">Ricavi mensili</h2>
<canvas #canvas></canvas>
</div>
`
})
export class RevenueChartComponent implements AfterViewInit, OnDestroy {
@ViewChild('canvas')
canvas!: ElementRef<HTMLCanvasElement>;
private chart?: Chart;
ngAfterViewInit(): void {
const config: ChartConfiguration = {
type: 'line',
data: {
labels: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu'],
datasets: [
{
label: 'Ricavi',
data: [12000, 15000, 14000, 21000, 26000, 31000],
borderColor: '#4f46e5',
backgroundColor: 'rgba(79, 70, 229, 0.15)',
tension: 0.35,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
},
scales: {
y: {
beginAtZero: true
}
}
}
};
this.chart = new Chart(this.canvas.nativeElement, config);
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
Why destroy the chart?
When an Angular component is removed from the page, Chart.js can leave listeners or objects in memory. Calling destroy() in ngOnDestroy() prevents memory leaks, especially in dashboards with many charts.
Updating data dynamically
updateChart(values: number[]): void {
if (!this.chart) {
return;
}
this.chart.data.datasets[0].data = values;
this.chart.update();
}
In a real project, values often come from an HTTP service, a global store or a signal.
Practical tips
- Use separate components for each complex chart.
- Don't initialise Chart.js before the canvas is available.
- Always destroy the chart in
ngOnDestroy(). - For SSR or Angular Universal, run Chart.js only in the browser.
- Normalise the data before passing it to the chart.
4. How to add animations in Angular
Animations improve how an interface is perceived, but they should be used judiciously. The goal is not to "decorate" the page, but to help users understand what is happening: panels opening, notifications appearing, transitions between states and form feedback.
Installing the Angular Animations package
npm install @angular/animations
Configuration in app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [
provideAnimations()
]
};
Example of a fade + slide animation
import { Component } from '@angular/core';
import {
animate,
style,
transition,
trigger
} from '@angular/animations';
@Component({
selector: 'app-notification',
standalone: true,
animations: [
trigger('fadeSlide', [
transition(':enter', [
style({
opacity: 0,
transform: 'translateY(12px)'
}),
animate(
'180ms ease-out',
style({
opacity: 1,
transform: 'translateY(0)'
})
)
]),
transition(':leave', [
animate(
'140ms ease-in',
style({
opacity: 0,
transform: 'translateY(12px)'
})
)
])
])
],
template: `
@if (visible) {
<div
@fadeSlide
class="rounded-xl border border-green-200 bg-green-50 p-4 text-green-800">
Operazione completata con successo.
</div>
}
<button
class="mt-4 rounded-lg bg-green-600 px-4 py-2 text-white"
type="button"
(click)="visible = !visible">
Mostra/Nascondi
</button>
`
})
export class NotificationComponent {
visible = true;
}
When to use an external library such as GSAP
Angular Animations is enough for many application interfaces. A library like GSAP becomes useful when you need complex timelines, coordinated animations, highly dynamic landing pages or sophisticated micro-interactions.
npm install gsap
Important rule: avoid animating heavy properties such as width, height or top when you can use transform and opacity, which are generally more efficient.
5. State management in Angular: Signals, NgRx and Akita
State management is one of the most important topics in Angular. At first a service with a few variables may seem enough, but as the app grows you need to choose a more structured approach.
Quick comparison
| Solution | When to use it | Advantages | Disadvantages |
|---|---|---|---|
| Signals | Simple to medium local or shared state | Modern syntax, simple reactivity, less boilerplate | Requires architectural discipline in large projects |
| NgRx | Enterprise applications, complex flows, auditability | Predictable pattern, DevTools, effects, selectors | More boilerplate and a learning curve |
| Akita | Leaner store, entity store, teams that want less boilerplate | Pragmatic API, convenient entity management | A less central ecosystem than NgRx |
Rule of thumb
- Use Signals for local state, UI state and small to medium features.
- Use NgRx when you have many asynchronous flows, roles, permissions, caching and advanced debugging.
- Use Akita if you want a structured store that is less verbose than NgRx.
- Don't introduce a global store if the problem can be solved with a simple service.
6. Hands-on tutorial: a store with Angular Signals
Signals let you represent reactive values in a simple way. They are particularly handy for creating small feature stores without introducing external libraries.
Let's create a Todo Store
import { Injectable, computed, signal } from '@angular/core';
export interface Todo {
id: number;
title: string;
done: boolean;
}
export type TodoFilter = 'all' | 'active' | 'done';
@Injectable({
providedIn: 'root'
})
export class TodoStore {
private readonly todosState = signal<Todo[]>([]);
readonly filter = signal<TodoFilter>('all');
readonly todos = this.todosState.asReadonly();
readonly activeCount = computed(() => {
return this.todosState().filter(todo => !todo.done).length;
});
readonly completedCount = computed(() => {
return this.todosState().filter(todo => todo.done).length;
});
readonly visibleTodos = computed(() => {
const filter = this.filter();
const todos = this.todosState();
if (filter === 'active') {
return todos.filter(todo => !todo.done);
}
if (filter === 'done') {
return todos.filter(todo => todo.done);
}
return todos;
});
addTodo(title: string): void {
const cleanTitle = title.trim();
if (!cleanTitle) {
return;
}
const todo: Todo = {
id: Date.now(),
title: cleanTitle,
done: false
};
this.todosState.update(current => [todo, ...current]);
}
toggleTodo(id: number): void {
this.todosState.update(current => {
return current.map(todo => {
if (todo.id !== id) {
return todo;
}
return {
...todo,
done: !todo.done
};
});
});
}
removeTodo(id: number): void {
this.todosState.update(current => {
return current.filter(todo => todo.id !== id);
});
}
clearCompleted(): void {
this.todosState.update(current => {
return current.filter(todo => !todo.done);
});
}
}
Component using the store
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoStore } from './todo.store';
@Component({
selector: 'app-todos',
standalone: true,
imports: [FormsModule],
template: `
<section class="mx-auto max-w-2xl rounded-2xl bg-white p-6 shadow-sm">
<h2 class="text-2xl font-bold text-slate-900">Todo con Signals</h2>
<div class="mt-4 flex gap-2">
<input
class="flex-1 rounded-lg border border-slate-300 px-3 py-2"
placeholder="Nuova attività"
[(ngModel)]="title">
<button
type="button"
class="rounded-lg bg-indigo-600 px-4 py-2 text-white"
(click)="add()">
Aggiungi
</button>
</div>
<div class="mt-4 flex gap-2">
<button type="button" (click)="store.filter.set('all')">Tutti</button>
<button type="button" (click)="store.filter.set('active')">Attivi</button>
<button type="button" (click)="store.filter.set('done')">Completati</button>
</div>
<ul class="mt-6 space-y-2">
@for (todo of store.visibleTodos(); track todo.id) {
<li class="flex items-center justify-between rounded-lg border border-slate-200 p-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
[checked]="todo.done"
(change)="store.toggleTodo(todo.id)">
<span [class.line-through]="todo.done">
{{ todo.title }}
</span>
</label>
<button
type="button"
class="text-red-600"
(click)="store.removeTodo(todo.id)">
Elimina
</button>
</li>
}
</ul>
<p class="mt-4 text-sm text-slate-600">
Attivi: {{ store.activeCount() }} -
Completati: {{ store.completedCount() }}
</p>
</section>
`
})
export class TodosComponent {
readonly store = inject(TodoStore);
title = '';
add(): void {
this.store.addTodo(this.title);
this.title = '';
}
}
Advantages of the Signals approach
- The code is readable.
- No actions, reducers or effects are needed for simple cases.
- Values derived with
computed()are very convenient. - State stays encapsulated in the service.
- The store's methods are easy to test.
A limit to remember
Signals do not mean "automatic architecture". If the project grows a lot, you still need to define conventions: naming, service separation, persistence, caching, error handling and communication with APIs.
7. Hands-on tutorial: state management with NgRx
NgRx implements a pattern inspired by Redux. It is a good fit when you want predictable, traceable and easily debuggable state. It is widely used in enterprise applications.
Installation
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools
Defining the model
export interface Product {
id: number;
name: string;
price: number;
}
export interface ProductsState {
items: Product[];
loading: boolean;
error: string | null;
}
export const initialProductsState: ProductsState = {
items: [],
loading: false,
error: null
};
Actions
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Product } from './product.model';
export const ProductsActions = createActionGroup({
source: 'Products',
events: {
'Load Products': emptyProps(),
'Load Products Success': props<{ products: Product[] }>(),
'Load Products Failure': props<{ error: string }>()
}
});
Reducer
import { createReducer, on } from '@ngrx/store';
import { ProductsActions } from './products.actions';
import { initialProductsState } from './product.model';
export const productsReducer = createReducer(
initialProductsState,
on(ProductsActions.loadProducts, state => ({
...state,
loading: true,
error: null
})),
on(ProductsActions.loadProductsSuccess, (state, { products }) => ({
...state,
items: products,
loading: false
})),
on(ProductsActions.loadProductsFailure, (state, { error }) => ({
...state,
loading: false,
error
}))
);
Selectors
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductsState } from './product.model';
export const selectProductsState =
createFeatureSelector<ProductsState>('products');
export const selectProducts =
createSelector(
selectProductsState,
state => state.items
);
export const selectProductsLoading =
createSelector(
selectProductsState,
state => state.loading
);
export const selectProductsError =
createSelector(
selectProductsState,
state => state.error
);
Effects
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { ProductsActions } from './products.actions';
import { ProductsApiService } from './products-api.service';
@Injectable()
export class ProductsEffects {
private readonly actions$ = inject(Actions);
private readonly api = inject(ProductsApiService);
loadProducts$ = createEffect(() => {
return this.actions$.pipe(
ofType(ProductsActions.loadProducts),
switchMap(() => {
return this.api.getProducts().pipe(
map(products => ProductsActions.loadProductsSuccess({ products })),
catchError(() => {
return of(
ProductsActions.loadProductsFailure({
error: 'Errore durante il caricamento dei prodotti.'
})
);
})
);
})
);
});
}
Store configuration
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { productsReducer } from './state/products.reducer';
import { ProductsEffects } from './state/products.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideStore({
products: productsReducer
}),
provideEffects([
ProductsEffects
]),
provideStoreDevtools({
maxAge: 25
})
]
};
Angular component with NgRx
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { ProductsActions } from './state/products.actions';
import {
selectProducts,
selectProductsError,
selectProductsLoading
} from './state/products.selectors';
@Component({
selector: 'app-products-page',
standalone: true,
template: `
<section class="space-y-4">
<h2 class="text-2xl font-bold">Prodotti</h2>
@if (loading()) {
<p>Caricamento...</p>
}
@if (error()) {
<p class="text-red-600">{{ error() }}</p>
}
<ul class="grid gap-4 md:grid-cols-3">
@for (product of products(); track product.id) {
<li class="rounded-xl border border-slate-200 p-4">
<h3 class="font-semibold">{{ product.name }}</h3>
<p>€ {{ product.price }}</p>
</li>
}
</ul>
</section>
`
})
export class ProductsPageComponent {
private readonly store = inject(Store);
readonly products = this.store.selectSignal(selectProducts);
readonly loading = this.store.selectSignal(selectProductsLoading);
readonly error = this.store.selectSignal(selectProductsError);
constructor() {
this.store.dispatch(ProductsActions.loadProducts());
}
}
When NgRx is the right choice
- You have many features sharing data.
- You have complex asynchronous flows.
- You need DevTools and time-travel debugging.
- The team must follow very clear patterns.
- State must be predictable and centralised.
8. When to use Akita
Akita is a more pragmatic and less verbose state management solution than NgRx. It is useful when you want a structured store without writing lots of actions and reducers.
Installation
npm install @datorama/akita
Conceptual store example
import { Injectable } from '@angular/core';
import {
EntityState,
EntityStore,
StoreConfig
} from '@datorama/akita';
export interface CartItem {
id: number;
name: string;
quantity: number;
}
export interface CartState extends EntityState<CartItem, number> {}
@Injectable({
providedIn: 'root'
})
@StoreConfig({
name: 'cart'
})
export class CartStore extends EntityStore<CartState> {
constructor() {
super();
}
}
Service to change the state
import { Injectable, inject } from '@angular/core';
import { CartStore, CartItem } from './cart.store';
@Injectable({
providedIn: 'root'
})
export class CartService {
private readonly cartStore = inject(CartStore);
addItem(item: CartItem): void {
this.cartStore.add(item);
}
updateQuantity(id: number, quantity: number): void {
this.cartStore.update(id, {
quantity
});
}
removeItem(id: number): void {
this.cartStore.remove(id);
}
clearCart(): void {
this.cartStore.reset();
}
}
Practical choice
If your team already knows NgRx and works on enterprise applications, NgRx remains a very solid choice. If instead you want a store that is quicker to implement and less ceremonious, Akita can be a good alternative. For new, small to medium applications, consider Signals first.
9. Advanced Reactive Forms in Angular
Reactive Forms are one of Angular's most powerful tools. They let you build complex forms, validate input, manage dynamic states, create FormArrays, cross-field validation and asynchronous checks tied to APIs.
Practical scenario
Let's create a profile form with:
- account details;
- email with asynchronous validation;
- password with a custom validator;
- password confirmation;
- a dynamic list of skills with
FormArray; - clear error messages in the template.
Custom validators
import {
AbstractControl,
AsyncValidatorFn,
ValidationErrors,
ValidatorFn
} from '@angular/forms';
import {
catchError,
map,
of,
switchMap,
timer
} from 'rxjs';
import { UsersApiService } from './users-api.service';
export function passwordStrengthValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = String(control.value || '');
const hasMinLength = value.length >= 8;
const hasUppercase = /[A-Z]/.test(value);
const hasNumber = /[0-9]/.test(value);
const hasSpecial = /[^A-Za-z0-9]/.test(value);
const valid =
hasMinLength &&
hasUppercase &&
hasNumber &&
hasSpecial;
if (valid) {
return null;
}
return {
weakPassword: {
hasMinLength,
hasUppercase,
hasNumber,
hasSpecial
}
};
};
}
export function passwordsMatchValidator(
passwordKey: string,
confirmPasswordKey: string
): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get(passwordKey)?.value;
const confirmPassword = group.get(confirmPasswordKey)?.value;
if (password === confirmPassword) {
return null;
}
return {
passwordsMismatch: true
};
};
}
export function uniqueEmailValidator(
usersApi: UsersApiService
): AsyncValidatorFn {
return (control: AbstractControl) => {
const email = String(control.value || '').trim();
if (!email) {
return of(null);
}
return timer(400).pipe(
switchMap(() => usersApi.emailExists(email)),
map(exists => {
return exists ? { emailTaken: true } : null;
}),
catchError(() => {
return of(null);
})
);
};
}
Example API service
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class UsersApiService {
private readonly http = inject(HttpClient);
emailExists(email: string): Observable<boolean> {
return this.http.get<boolean>(
`/api/users/email-exists?email=${encodeURIComponent(email)}`
);
}
}
Component with a complex Reactive Form
import { Component, inject } from '@angular/core';
import {
FormArray,
NonNullableFormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import {
passwordStrengthValidator,
passwordsMatchValidator,
uniqueEmailValidator
} from './profile.validators';
import { UsersApiService } from './users-api.service';
@Component({
selector: 'app-profile-form',
standalone: true,
imports: [
ReactiveFormsModule
],
templateUrl: './profile-form.component.html'
})
export class ProfileFormComponent {
private readonly fb = inject(NonNullableFormBuilder);
private readonly usersApi = inject(UsersApiService);
readonly form = this.fb.group({
account: this.fb.group(
{
name: [
'',
[
Validators.required,
Validators.minLength(2)
]
],
email: [
'',
{
validators: [
Validators.required,
Validators.email
],
asyncValidators: [
uniqueEmailValidator(this.usersApi)
],
updateOn: 'blur'
}
],
password: [
'',
[
Validators.required,
passwordStrengthValidator()
]
],
confirmPassword: [
'',
[
Validators.required
]
]
},
{
validators: [
passwordsMatchValidator('password', 'confirmPassword')
]
}
),
profile: this.fb.group({
role: ['frontend'],
bio: ['']
}),
skills: this.fb.array([
this.createSkill()
])
});
get skills(): FormArray {
return this.form.controls.skills;
}
createSkill() {
return this.fb.group({
name: [
'',
[
Validators.required
]
],
years: [
0,
[
Validators.required,
Validators.min(0),
Validators.max(40)
]
]
});
}
addSkill(): void {
this.skills.push(this.createSkill());
}
removeSkill(index: number): void {
if (this.skills.length === 1) {
return;
}
this.skills.removeAt(index);
}
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('Payload form:', this.form.getRawValue());
}
}
Form HTML template
<form
class="mx-auto max-w-3xl space-y-8 rounded-2xl bg-white p-6 shadow-sm"
[formGroup]="form"
(ngSubmit)="submit()">
<section formGroupName="account" class="space-y-4">
<h2 class="text-xl font-bold text-slate-900">Account</h2>
<div>
<label class="block text-sm font-medium text-slate-700">
Nome
</label>
<input
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
type="text"
formControlName="name">
@if (form.controls.account.controls.name.touched &&
form.controls.account.controls.name.hasError('required')) {
<p class="mt-1 text-sm text-red-600">
Il nome è obbligatorio.
</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700">
Email
</label>
<input
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
type="email"
formControlName="email">
@if (form.controls.account.controls.email.pending) {
<p class="mt-1 text-sm text-slate-500">
Verifica email in corso...
</p>
}
@if (form.controls.account.controls.email.hasError('emailTaken')) {
<p class="mt-1 text-sm text-red-600">
Questa email è già registrata.
</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700">
Password
</label>
<input
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
type="password"
formControlName="password">
@if (form.controls.account.controls.password.hasError('weakPassword')) {
<p class="mt-1 text-sm text-red-600">
La password deve avere almeno 8 caratteri, una maiuscola,
un numero e un carattere speciale.
</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700">
Conferma password
</label>
<input
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
type="password"
formControlName="confirmPassword">
@if (form.controls.account.hasError('passwordsMismatch')) {
<p class="mt-1 text-sm text-red-600">
Le password non coincidono.
</p>
}
</div>
</section>
<section formGroupName="profile" class="space-y-4">
<h2 class="text-xl font-bold text-slate-900">Profilo</h2>
<div>
<label class="block text-sm font-medium text-slate-700">
Ruolo
</label>
<select
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
formControlName="role">
<option value="frontend">Frontend Developer</option>
<option value="backend">Backend Developer</option>
<option value="fullstack">Full Stack Developer</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">
Bio
</label>
<textarea
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
rows="4"
formControlName="bio"></textarea>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-slate-900">
Competenze
</h2>
<button
type="button"
class="rounded-lg bg-indigo-600 px-4 py-2 text-white"
(click)="addSkill()">
Aggiungi competenza
</button>
</div>
<div formArrayName="skills" class="space-y-3">
@for (skill of skills.controls; track $index) {
<div
class="grid gap-3 rounded-xl border border-slate-200 p-4 md:grid-cols-[1fr_140px_auto]"
[formGroupName]="$index">
<input
class="rounded-lg border border-slate-300 px-3 py-2"
type="text"
placeholder="Competenza"
formControlName="name">
<input
class="rounded-lg border border-slate-300 px-3 py-2"
type="number"
placeholder="Anni"
formControlName="years">
<button
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-red-600"
(click)="removeSkill($index)">
Rimuovi
</button>
</div>
}
</div>
</section>
<button
type="submit"
class="w-full rounded-xl bg-slate-900 px-5 py-3 font-semibold text-white disabled:opacity-50"
[disabled]="form.pending">
Salva profilo
</button>
</form>
Common mistakes with Reactive Forms
- Putting too much logic in the template.
- Not using
markAllAsTouched()on submit. - Making asynchronous calls on every keystroke without
updateOn: 'blur'or debounce. - Not separating validators into dedicated files.
- Not handling the
pendingstate of asynchronous validation. - Not typing forms correctly in large projects.
Best practices
- Use Reactive Forms for complex and dynamic forms.
- Use template-driven forms only for very simple forms.
- Extract custom validators.
- Show error messages only after
touchedor submit. - Disable submit while the form is
pending. - Normalise the payload before sending it to the backend.
10. Testing stores and forms
A hands-on tutorial isn't complete without tests. There's no need to test every detail of the framework, but it is important to verify custom logic: stores, validators and data transformation.
Testing a password validator
import { FormControl } from '@angular/forms';
import { passwordStrengthValidator } from './profile.validators';
describe('passwordStrengthValidator', () => {
it('dovrebbe invalidare una password debole', () => {
const control = new FormControl('abc');
const validator = passwordStrengthValidator();
const result = validator(control);
expect(result).toEqual(
jasmine.objectContaining({
weakPassword: jasmine.any(Object)
})
);
});
it('dovrebbe accettare una password forte', () => {
const control = new FormControl('Angular2026!');
const validator = passwordStrengthValidator();
const result = validator(control);
expect(result).toBeNull();
});
});
Testing the Signals store
import { TodoStore } from './todo.store';
describe('TodoStore', () => {
it('dovrebbe aggiungere una todo', () => {
const store = new TodoStore();
store.addTodo('Studiare Angular Signals');
expect(store.todos().length).toBe(1);
expect(store.todos()[0].title).toBe('Studiare Angular Signals');
});
it('dovrebbe filtrare le todo completate', () => {
const store = new TodoStore();
store.addTodo('Prima attività');
const id = store.todos()[0].id;
store.toggleTodo(id);
store.filter.set('done');
expect(store.visibleTodos().length).toBe(1);
expect(store.visibleTodos()[0].done).toBeTrue();
});
});
11. Performance and maintenance
In Angular, performance and maintainability go hand in hand. A fast but chaotic app becomes hard to evolve. A well-organised app, on the other hand, also tends to be easier to optimise.
Practical tips
- Split features into small components.
- Use lazy loading for heavy pages and areas.
- Avoid complex logic inside templates.
- Use
trackin@forloops. - Destroy listeners, charts and subscriptions that aren't managed automatically.
- Don't put everything in the global store.
- Use computed values instead of recalculating data in the template.
- Avoid overly aggressive asynchronous validation.
12. Final checklist for a solid Angular project
- Does the project have a clear structure?
- Are components small and focused?
- Are external libraries encapsulated in components or services?
- Is Chart.js destroyed correctly?
- Is Tailwind used consistently?
- Is state local when possible and global only when needed?
- Do complex forms use Reactive Forms?
- Are custom validators tested?
- Are form errors understandable for the user?
- Do asynchronous calls have error handling?
- Is the code readable for a new developer too?
FAQ
Signals or NgRx: which is better?
It depends on complexity. Signals is great for simple or medium state. NgRx is better suited to large applications with complex flows, advanced debugging and large teams.
Can I use Signals and NgRx together?
Yes. You can use NgRx for global state and Signals for local UI state, filters, panels, tabs and derived values in components.
Is Tailwind CSS suitable for enterprise applications?
Yes, if used with clear conventions. It is important to create shared components and not duplicate classes everywhere without a plan.
Is Chart.js enough for complex dashboards?
For many dashboards, yes. If you need extremely advanced charts, custom interactions or large amounts of data, you might consider more specialised libraries.
When should I use Reactive Forms?
Use Reactive Forms when you have complex validation, dynamic forms, FormArrays, interdependent controls or asynchronous validation.
How should I handle form errors?
Show errors only after the field has been touched or after submit. Avoid showing too many errors while the user is still typing.
Does asynchronous validation slow the app down?
It can if it runs on every character. Use updateOn: 'blur', debounce and caching when needed.
Is Akita still a valid choice?
It can be in projects that already use it or in teams that prefer a lighter approach. For new projects, carefully consider Signals and NgRx before introducing an external library.
Is it better to use wrapper libraries for Chart.js?
It depends. A wrapper can simplify integration, but direct use gives more control. For simple cases, direct integration is often enough.
How do I avoid Angular components that are too large?
Extract logic into services, stores or validators. Split the UI into smaller components and use inputs/outputs or shared services when needed.