Angular in 10 steps - 10 of 10

10 - Dalla teoria alla pratica: una libreria di videogiochi
Eccoci all'ultimo capitolo. Nove puntate di teoria, esempi e avvertimenti: adesso mettiamo tutto in fila e costruiamo un'applicazione vera, dall'inizio alla fine.
Il progetto si chiama GameShelf: una libreria personale di videogiochi. Cerchi un gioco in un database online, lo aggiungi alla tua collezione, e poi gestisci lo stato di ogni titolo: da giocare, in corso, finito. Il tipo di app che sembra un giocattolo e invece contiene tutti i problemi veri: autenticazione, chiamate a un'API esterna, stato condiviso, form, navigazione protetta.
La regola di questo capitolo: niente di nuovo. Ogni pezzo che scriveremo viene da un capitolo della serie: componenti (3), router con guardie e lazy loading (4), signals (5), form (6), HTTP e Resource API (7), stato nei service (8), interceptor e pipe (9). Considerale l'esame finale.
🎮 Cosa ci serve
Per la ricerca dei giochi usiamo RAWG (rawg.io), il più grande database di videogiochi con API pubblica: registrati e ottieni una chiave gratuita in due minuti. Il login invece sarà finto (niente backend, un token fasullo in localStorage), ma il flusso (form, service, guardia, redirect) è identico a quello di un'app vera: quando avrai un backend, cambierai solo il contenuto di un metodo.
Una chiave API in un'app frontend non è mai davvero segreta: chiunque apra i DevTools la vede. Per RAWG va bene così (è pensata per questo), ma non committare chiavi in repository pubblici e, per servizi seri, fai passare le chiamate da un tuo backend.
🧰 Setup del progetto
Si parte come nel capitolo 2:
ng new gameshelf --style=css
cd gameshelfPer l'interfaccia non scriveremo CSS a mano: usiamo Tailwind CSS con daisyUI. Installazione:
npm install tailwindcss @tailwindcss/postcss postcss daisyuiPoi crea un file .postcssrc.json nella radice del progetto:
1{
2 "plugins": {
3 "@tailwindcss/postcss": {}
4 }
5}E in src/styles.css bastano due righe:
@import "tailwindcss";
@plugin "daisyui";🌼 Due parole su daisyUI
Tailwind è un framework CSS utility-first: invece di scrivere classi tue,
componi lo stile con classi atomiche (flex, p-4, text-xl...). Potente, ma
per un bottone decente servono dieci classi. daisyUI è un plugin che
aggiunge sopra Tailwind delle classi componente già disegnate:
<button class="btn btn-primary">Salva</button>
<div class="card bg-base-100 shadow">...</div>
<span class="badge badge-success">finito</span>btn, card, badge, navbar, input: nomi che si spiegano da soli, temi
chiari e scuri inclusi. Per un progetto didattico è perfetto: l'aspetto è
subito presentabile e noi restiamo concentrati su Angular. E dove serve un
ritocco, le utility di Tailwind si affiancano senza conflitti.
🗺️ Lo scheletro: rotte e configurazione
Tre pagine: login, ricerca e libreria. Le ultime due protette dalla guardia e caricate lazy, come da capitolo 4:
1// app.routes.ts
2import { Routes } from '@angular/router';
3import { authGuard } from './auth/auth.guard';
4
5export const routes: Routes = [
6 { path: '', redirectTo: 'search', pathMatch: 'full' },
7 {
8 path: 'login',
9 loadComponent: () =>
10 import('./auth/login.component').then((m) => m.LoginComponent),
11 },
12 {
13 path: 'search',
14 loadComponent: () =>
15 import('./search/search.component').then((m) => m.SearchComponent),
16 canActivate: [authGuard],
17 },
18 {
19 path: 'library',
20 loadComponent: () =>
21 import('./library/library.component').then((m) => m.LibraryComponent),
22 canActivate: [authGuard],
23 },
24 { path: '**', redirectTo: 'search' },
25];In app.config.ts registriamo router, HttpClient e un interceptor che
vedremo tra poco:
1import { ApplicationConfig } from '@angular/core';
2import { provideRouter } from '@angular/router';
3import { provideHttpClient, withInterceptors } from '@angular/common/http';
4import { routes } from './app.routes';
5import { rawgKeyInterceptor } from './core/rawg-key.interceptor';
6
7export const appConfig: ApplicationConfig = {
8 providers: [
9 provideRouter(routes),
10 provideHttpClient(withInterceptors([rawgKeyInterceptor])),
11 ],
12};🔑 Autenticazione: service, guardia e login
Lo stato di autenticazione vive in un service, col pattern del capitolo 8:
signal privato, lettura pubblica con asReadonly(), scritture solo via metodi.
1// auth/auth.service.ts
2import { Injectable, signal } from '@angular/core';
3
4@Injectable({ providedIn: 'root' })
5export class AuthService {
6 private _isLoggedIn = signal(!!localStorage.getItem('gameshelf-token'));
7 readonly isLoggedIn = this._isLoggedIn.asReadonly();
8
9 login(email: string, password: string): boolean {
10 if (!email || !password) return false;
11
12 // qui, in un'app vera, ci sarebbe una POST al backend (capitolo 7)
13 localStorage.setItem('gameshelf-token', 'fake-token');
14 this._isLoggedIn.set(true);
15 return true;
16 }
17
18 logout() {
19 localStorage.removeItem('gameshelf-token');
20 this._isLoggedIn.set(false);
21 }
22}La guardia è quella del capitolo 4, parola per parola:
1// auth/auth.guard.ts
2import { inject } from '@angular/core';
3import { CanActivateFn, Router } from '@angular/router';
4import { AuthService } from './auth.service';
5
6export const authGuard: CanActivateFn = () => {
7 return inject(AuthService).isLoggedIn()
8 ? true
9 : inject(Router).parseUrl('/login');
10};E la pagina di login è un form template-driven (capitolo 6): due campi, due signal, submit che delega al service e naviga.
1// auth/login.component.ts
2import { Component, inject, signal } from '@angular/core';
3import { FormsModule } from '@angular/forms';
4import { Router } from '@angular/router';
5import { AuthService } from './auth.service';
6
7@Component({
8 selector: 'app-login',
9 imports: [FormsModule],
10 templateUrl: './login.component.html',
11})
12export class LoginComponent {
13 private auth = inject(AuthService);
14 private router = inject(Router);
15
16 email = signal('');
17 password = signal('');
18 errore = signal(false);
19
20 submit() {
21 if (this.auth.login(this.email(), this.password())) {
22 this.router.navigateByUrl('/search');
23 } else {
24 this.errore.set(true);
25 }
26 }
27}1<!-- auth/login.component.html -->
2<div class="min-h-screen flex items-center justify-center bg-base-200">
3 <form
4 (ngSubmit)="submit()"
5 class="card bg-base-100 shadow-xl p-8 w-full max-w-sm flex flex-col gap-4"
6 >
7 <h1 class="text-2xl font-bold">GameShelf 🎮</h1>
8
9 <input
10 name="email"
11 type="email"
12 [(ngModel)]="email"
13 placeholder="Email"
14 class="input input-bordered w-full"
15 required
16 />
17 <input
18 name="password"
19 type="password"
20 [(ngModel)]="password"
21 placeholder="Password"
22 class="input input-bordered w-full"
23 required
24 />
25
26 @if (errore()) {
27 <div class="alert alert-error">Inserisci email e password.</div>
28 }
29
30 <button type="submit" class="btn btn-primary">Entra</button>
31 </form>
32</div>Nota la solita coppia del capitolo 6: [(ngModel)]="email" (il signal, non la
chiamata) e l'attributo name su ogni campo.
🚦 L'interceptor per la chiave RAWG
RAWG vuole la chiave API come query param su ogni richiesta. Potremmo appiccicarla a mano a ogni URL... oppure usare quello che abbiamo imparato nel capitolo 9:
1// core/rawg-key.interceptor.ts
2import { HttpInterceptorFn } from '@angular/common/http';
3
4const RAWG_KEY = 'LA_TUA_CHIAVE_RAWG';
5
6export const rawgKeyInterceptor: HttpInterceptorFn = (req, next) => {
7 if (req.url.startsWith('https://api.rawg.io')) {
8 return next(req.clone({ setParams: { key: RAWG_KEY } }));
9 }
10 return next(req);
11};Da questo momento ogni chiamata verso RAWG esce già firmata, e nessun service deve saperlo. Questo è il mestiere degli interceptor.
🔍 La pagina di ricerca
Il cuore della pagina è un httpResource (capitolo 7) pilotato da un signal.
Scelta di design: la richiesta parte al submit del form, non a ogni tasto
premuto: per questo i signal sono due, uno per il testo digitato e uno per la
query confermata.
1// search/search.component.ts
2import { Component, computed, inject, signal } from '@angular/core';
3import { FormsModule } from '@angular/forms';
4import { httpResource } from '@angular/common/http';
5import { DatePipe } from '@angular/common';
6import { LibraryService } from '../library/library.service';
7import { Game, RawgSearchResponse } from '../models/game.model';
8
9@Component({
10 selector: 'app-search',
11 imports: [FormsModule, DatePipe],
12 templateUrl: './search.component.html',
13})
14export class SearchComponent {
15 library = inject(LibraryService);
16
17 testo = signal('');
18 query = signal('');
19
20 risultati = httpResource<RawgSearchResponse>(() =>
21 this.query()
22 ? `https://api.rawg.io/api/games?search=${encodeURIComponent(this.query())}&page_size=12`
23 : undefined
24 );
25
26 giochi = computed(() => this.risultati.value()?.results ?? []);
27
28 cerca() {
29 this.query.set(this.testo().trim());
30 }
31
32 aggiungi(game: Game) {
33 this.library.add(game);
34 }
35}Il trucco è nella funzione passata a httpResource: se la query è vuota
restituisce undefined, e Angular semplicemente non fa la richiesta. Quando
la query cambia, la richiesta riparte da sola. I modelli:
1// models/game.model.ts
2export interface Game {
3 id: number;
4 name: string;
5 background_image: string | null;
6 released: string | null;
7 metacritic: number | null;
8}
9
10export interface RawgSearchResponse {
11 results: Game[];
12}E il template, dove tornano il control flow del capitolo 5, la date pipe del
capitolo 9 e le card di daisyUI:
1<!-- search/search.component.html -->
2<div class="p-6 max-w-5xl mx-auto">
3 <form (ngSubmit)="cerca()" class="flex gap-2 mb-6">
4 <input
5 name="testo"
6 [(ngModel)]="testo"
7 placeholder="Cerca un gioco..."
8 class="input input-bordered flex-1"
9 />
10 <button type="submit" class="btn btn-primary">Cerca</button>
11 </form>
12
13 @if (risultati.isLoading()) {
14 <span class="loading loading-spinner loading-lg"></span>
15 } @else if (risultati.error()) {
16 <div class="alert alert-error">Qualcosa è andato storto. Riprova.</div>
17 } @else {
18 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
19 @for (game of giochi(); track game.id) {
20 <div class="card bg-base-100 shadow">
21 @if (game.background_image) {
22 <figure>
23 <img [src]="game.background_image" [alt]="game.name" />
24 </figure>
25 }
26 <div class="card-body">
27 <h2 class="card-title">{{ game.name }}</h2>
28 <p class="text-sm opacity-70">
29 {{ game.released | date: 'longDate' }}
30 </p>
31 <div class="card-actions justify-end">
32 <button
33 class="btn btn-sm btn-primary"
34 [disabled]="library.has(game.id)"
35 (click)="aggiungi(game)"
36 >
37 {{ library.has(game.id) ? 'In libreria ✓' : 'Aggiungi' }}
38 </button>
39 </div>
40 </div>
41 </div>
42 } @empty {
43 @if (query()) {
44 <p>Nessun risultato per "{{ query() }}".</p>
45 }
46 }
47 </div>
48 }
49</div>Il bottone "Aggiungi" interroga direttamente il service della libreria: se il gioco c'è già si disabilita da solo. Due componenti, uno stato condiviso, zero eventi da propagare: il capitolo 8 all'opera.
📚 Il service della libreria
Ed eccolo, il service. Stato in un signal privato, lettura con asReadonly(),
statistiche derivate con computed, e la persistenza in localStorage affidata
a un effect, che, come dicevamo al capitolo 5, è esattamente il posto dei
side effect:
1// library/library.service.ts
2import { computed, effect, Injectable, signal } from '@angular/core';
3import { Game } from '../models/game.model';
4
5export type GameStatus = 'backlog' | 'playing' | 'done';
6
7export interface LibraryGame extends Game {
8 status: GameStatus;
9}
10
11const STORAGE_KEY = 'gameshelf-library';
12
13@Injectable({ providedIn: 'root' })
14export class LibraryService {
15 private _games = signal<LibraryGame[]>(this.load());
16
17 readonly games = this._games.asReadonly();
18 readonly count = computed(() => this._games().length);
19 readonly doneCount = computed(
20 () => this._games().filter((g) => g.status === 'done').length
21 );
22
23 constructor() {
24 // ogni volta che la libreria cambia, la salviamo. Tutto qui.
25 effect(() => {
26 localStorage.setItem(STORAGE_KEY, JSON.stringify(this._games()));
27 });
28 }
29
30 has(id: number): boolean {
31 return this._games().some((g) => g.id === id);
32 }
33
34 add(game: Game) {
35 if (this.has(game.id)) return;
36 this._games.update((games) => [...games, { ...game, status: 'backlog' }]);
37 }
38
39 remove(id: number) {
40 this._games.update((games) => games.filter((g) => g.id !== id));
41 }
42
43 setStatus(id: number, status: GameStatus) {
44 this._games.update((games) =>
45 games.map((g) => (g.id === id ? { ...g, status } : g))
46 );
47 }
48
49 private load(): LibraryGame[] {
50 try {
51 return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
52 } catch {
53 return [];
54 }
55 }
56}Rileggi l'effect nel costruttore: legge _games(), quindi si ri-esegue a
ogni modifica della libreria. Aggiungi, rimuovi, cambi stato: il salvataggio
avviene da solo, e al riavvio load() ritrova tutto. Persistenza in sei righe.
🗄️ La pagina libreria
Ultima pagina: la collezione, con un filtro per stato. Il filtro è un signal,
la lista filtrata un computed: a questo punto della serie, il riflesso
dovrebbe essere automatico.
1// library/library.component.ts
2import { Component, computed, inject, signal } from '@angular/core';
3import { GameStatus, LibraryService } from './library.service';
4
5const LABELS: Record<GameStatus, string> = {
6 backlog: 'Da giocare',
7 playing: 'In corso',
8 done: 'Finito',
9};
10
11@Component({
12 selector: 'app-library',
13 templateUrl: './library.component.html',
14})
15export class LibraryComponent {
16 library = inject(LibraryService);
17
18 readonly stati = ['backlog', 'playing', 'done'] as const;
19 readonly labels = LABELS;
20
21 filtro = signal<GameStatus | 'all'>('all');
22
23 giochiFiltrati = computed(() => {
24 const filtro = this.filtro();
25 const games = this.library.games();
26 return filtro === 'all' ? games : games.filter((g) => g.status === filtro);
27 });
28
29 cambiaStato(id: number, event: Event) {
30 const status = (event.target as HTMLSelectElement).value as GameStatus;
31 this.library.setStatus(id, status);
32 }
33}1<!-- library/library.component.html -->
2<div class="p-6 max-w-5xl mx-auto">
3 <div class="flex items-center justify-between mb-6">
4 <h1 class="text-2xl font-bold">
5 La mia libreria
6 <span class="badge badge-primary">{{ library.count() }}</span>
7 <span class="badge badge-success">{{ library.doneCount() }} finiti</span>
8 </h1>
9
10 <div class="join">
11 <button
12 class="btn btn-sm join-item"
13 [class.btn-active]="filtro() === 'all'"
14 (click)="filtro.set('all')"
15 >
16 Tutti
17 </button>
18 @for (stato of stati; track stato) {
19 <button
20 class="btn btn-sm join-item"
21 [class.btn-active]="filtro() === stato"
22 (click)="filtro.set(stato)"
23 >
24 {{ labels[stato] }}
25 </button>
26 }
27 </div>
28 </div>
29
30 <div class="flex flex-col gap-3">
31 @for (game of giochiFiltrati(); track game.id) {
32 <div class="card card-side bg-base-100 shadow">
33 @if (game.background_image) {
34 <figure class="w-40">
35 <img
36 [src]="game.background_image"
37 [alt]="game.name"
38 class="h-full object-cover"
39 />
40 </figure>
41 }
42 <div class="card-body flex-row items-center justify-between">
43 <h2 class="card-title">{{ game.name }}</h2>
44 <div class="flex items-center gap-2">
45 <select
46 class="select select-bordered select-sm"
47 [value]="game.status"
48 (change)="cambiaStato(game.id, $event)"
49 >
50 @for (stato of stati; track stato) {
51 <option [value]="stato">{{ labels[stato] }}</option>
52 }
53 </select>
54 <button
55 class="btn btn-sm btn-ghost text-error"
56 (click)="library.remove(game.id)"
57 >
58 Rimuovi
59 </button>
60 </div>
61 </div>
62 </div>
63 } @empty {
64 <p class="opacity-70">
65 Qui non c'è ancora niente. Vai nella ricerca e aggiungi il primo gioco!
66 </p>
67 }
68 </div>
69</div>Ogni interazione (cambio stato, rimozione, filtro) passa dai signals: la
lista si aggiorna, i badge in alto si ricalcolano, l'effect risalva. Nessun
codice di sincronizzazione, da nessuna parte.
🧭 La barra di navigazione
Chiudiamo il cerchio nel componente radice: navbar con i link (e
routerLinkActive dal capitolo 4), il contatore della libreria che si aggiorna
da qualunque pagina, e il logout.
1// app.component.ts
2import { Component, inject } from '@angular/core';
3import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
4import { AuthService } from './auth/auth.service';
5import { LibraryService } from './library/library.service';
6
7@Component({
8 selector: 'app-root',
9 imports: [RouterOutlet, RouterLink, RouterLinkActive],
10 templateUrl: './app.component.html',
11})
12export class AppComponent {
13 auth = inject(AuthService);
14 library = inject(LibraryService);
15 private router = inject(Router);
16
17 logout() {
18 this.auth.logout();
19 this.router.navigateByUrl('/login');
20 }
21}1<!-- app.component.html -->
2@if (auth.isLoggedIn()) {
3 <div class="navbar bg-base-100 shadow">
4 <div class="flex-1 font-bold">GameShelf 🎮</div>
5 <div class="flex gap-2">
6 <a routerLink="/search" routerLinkActive="btn-active" class="btn btn-ghost btn-sm">
7 Cerca
8 </a>
9 <a routerLink="/library" routerLinkActive="btn-active" class="btn btn-ghost btn-sm">
10 Libreria
11 <span class="badge badge-sm badge-primary">{{ library.count() }}</span>
12 </a>
13 <button class="btn btn-ghost btn-sm" (click)="logout()">Esci</button>
14 </div>
15 </div>
16}
17
18<router-outlet />ng serve, login con credenziali qualsiasi, e GameShelf è vivo: cerca "Hollow
Knight", aggiungilo, segnalo "in corso" (siamo onesti: ci resterà per mesi), e
guarda il badge della navbar contare per te.
🏁 Fine della corsa
Dieci capitoli fa Angular era "quel framework enterprise con troppa roba dentro". Oggi hai costruito un'app completa: componenti standalone, rotte protette e lazy, stato reattivo coi signals, form, un'API esterna con tanto di interceptor, persistenza. E soprattutto hai un metodo: sai dove va ogni pezzo, che è la cosa che separa chi copia i tutorial da chi costruisce.
Da qui in poi i compiti li dai tu a te stesso: aggiungi una pagina di dettaglio con i parametri di rotta, un rating personale coi reactive forms, o sostituisci il login finto con un backend vero. Gli attrezzi li hai tutti.
Grazie di essere arrivato fin qui. E se GameShelf ti sfugge di mano e diventa il tuo prossimo progetto serio, beh, scrivimi: le storie così sono le mie preferite. またね!