Uvod
U ovom članku ćemo kreirati jednostavnu eCommerce platformu koristeći Signale, ulaze/modele (Inputs/Models) bazirane na Signalima, Linked Signal (Povezani signal) i Effect, reaktivnu funkciju koja prati zavisnosti signala.
Takođe, dok iščekujemo forme bazirane na Signalima, u današnjem tekstu iskoristićemo Template Driven forme koje se odlično i prirodno uklapaju uz Signale.
UI Store (Skladište Korisničkog Interfejsa)
UI Store služi kao jedinstven izvor istine za dati deo aplikacije. On upravlja stanjem specifičnim za korisnički interfejs (UI) i opisuje ponašanje aplikacije. Jedna veb aplikacija može imati više store-ova (lokalni ili tzv. feature store).
Signali (Signals)
Signali, kao nova reaktivna primitiva, postali su prirodan i najnoviji način upravljanja reaktivnošću u Angular veb aplikacijama. U poređenju sa RxJS-om, Signali su veoma jednostavni. Signali mogu sadržati jednu vrednost.
Čitanje vrednosti signala je prosto - samo se pozove getter funkcija ovako:
const value = signal(0);
console.log('Pročitaj vrednost pozivom getera: ', value());
// Rezlutat: 0
A postavljanje ili ažuriranje vrednosti radi se ovako:
value.set(1);
console.log('Vrednost nakon set: ', value());
// Rezlutat: 1
value.update(v => v + 1);
console.log('Vrednost nakon update: ', value());
// Rezultat: 2
Za razliku od standardnih getter funkcija, Signali su memoizovani, što znači da poziv u templejtu neće ponovo trigerovati nov ciklus change detection-a osim ako se vrednost zapravo i nije promenila.
<p>Trenutna vrednost: {{ value() }}</p>
Sloj Servisa (Service Layer)
Naša aplikacija mora da dobavlja prave podatke sa nekog API-ja. Ovde ćemo mokovati proces izvlačenja "Products" (Proizvoda). Hajde da kreiramo servis:
ng generate service services/products
Kada ga generišemo, kreiramo naš mockovani API:
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { Product } from '../interfaces/product.interface';
const PRODUCTS: Product[] = [
{
id: 'prod-001',
name: 'Wireless Headphones',
description:
'Noise-cancelling over-ear Bluetooth headphones with 30 hours battery life.',
price: 129.99,
inStock: true,
},
{
id: 'prod-002',
name: 'Smart Water Bottle',
description:
'Tracks your water intake and glows to remind you to stay hydrated.',
price: 49.99,
inStock: true,
},
// ...
{
id: 'prod-009',
name: 'Laptop Stand',
description: 'Adjustable aluminum stand for laptops up to 17 inches.',
price: 22.75,
inStock: true,
},
];
@Injectable({
providedIn: 'root',
})
export class ProductsService {
getAll() {
return of(PRODUCTS);
}
}
Naravno definisaćemo Product interfejs pokretanjem CLI komande:
ng generate interface interfaces/product
I popunite fajl:
export interface Product {
id: string;
name: string;
description: string;
price: number;
inStock: boolean;
}
Stranica Liste Proizvoda
Zarad ovog demona, app će biti jako prost. Koristiću App komponentu samo za dodavanje proizvoda u korpu bez nekih Master/Details flow-ova.
Za početak injektujemo ProductsService:
@Component({
selector: 'app-root',
template: `
<h1>eCommerce Demo</h1>
`,
})
export class AppComponent {
readonly productsService = inject(ProductsService);
U prošlosti, možda bismo koristili metodu ngOnInit sa pretplatom na Observable
ovako:
export class App implements OnInit {
readonly productsService = inject(ProductsService);
private readonly destroyRef = inject(DestroyRef);
products: Product[] = [];
ngOnInit(): void {
this.productsService.getAll()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(products => this.products = products);
}
Međutim, danas imamo bolji način pomoću toSignal:
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-root',
template: `
<h1>eCommerce Demo</h1>
`,
})
export class AppComponents {
readonly productsService = inject(ProductsService);
readonly products = toSignal(this.productsService.getAll(), {
initialValue: [],
});
Moćan trik sa toSignal iz paketa
@angular/core/rxjs-interop jeste što radi kompletno unsubscribe
(odjavljivanje) za nas. I manje koda (boilerplate).
Kartica Proizvoda (Product Card Komponenta)
Radićemo prezentacionu komponentu za Proizvod.
ng generate component components/product-card
Definišemo klasu:
import { Component, input, computed, output } from '@angular/core';
import { Product } from '../../interfaces/product.interface';
@Component({
selector: 'app-product-card',
imports: [],
templateUrl: './product-card.component.html',
styleUrl: './product-card.component.css',
})
export class ProductCardComponent {
readonly product = input.required<Product>();
readonly addToCart = output<Product>();
protected readonly stockAvailibility = computed(
() => this.product().inStock ? 'In Stock' : 'Out of Stock'
);
}
Iskoristili smo input kontrolisan signalom uz pomoć input.required() i naravno
computed funkciju.
A ovo ide u templejt:
<div class="product-card">
<h2 class="product-name">{{ product().name }}</h2>
<p class="product-description">{{ product().description }}</p>
<p class="product-price">${{ product().price }}</p>
<p class="product-stock" [class.out-of-stock]="!product().inStock">
{{ stockAvailibility() }}
</p>
<button
class="add-button"
(click)="addToCart.emit(product())"
[disabled]="!product().inStock"
>
Dodaj u korpu
</button>
</div>
Prikazivanje Proizvoda
Sećate se kako smo pripremili signale preko RxJS-a i toSignal u koracima
ranije? Sada ćemo izlistati kartice u AppComponent.
@Component({
selector: 'app-root',
imports: [
ProductCardComponent,
],
template: `
<h1>eCommerce Demo</h1>
<div>
@for (product of products(); track product.id) {
<app-product-card [product]="product" (addToCart)="false"/>
}
</div>
`,
})
export class AppComponents {...}
Skladište Korpe (Shopping Cart Store)
Ranije, ako ste preskočili NgRx, pisali biste BehaviorSubject u RxJS stilu.
Sada slična filozofija prelazi na Signale:
ng generate service stores/shopping-cart-store
Ideja je da je servisu isključiva odgovornost da čuva podatke, dakle čist Store pattern bez sporednih efekata (side-efekata).
export interface CustomerDetails {
firstName: string;
lastName: string;
street: string;
city: string;
postalCode: string;
}
export interface ShoppingCartItem {
product: Product;
quantity: number;
}
export interface ShoppingCartStore {
items: ShoppingCartItem[];
totalPrice: number;
customerDetails: CustomerDetails;
discount: number;
}
Centralno skladište narudžbina (Cart) sadržaće informacije koje stavke su ubačene, informacije kupca i naravno totalnu cenu. Definisaćemo bazne oblike i ključne reči.
const INITIAL_ITEMS: ShoppingCartItem[] = [];
const INITAL_CUSTOMER_DETAILS: CustomerDetails = {
firstName: '',
lastName: '',
street: '',
city: '',
postalCode: '',
};
@Injectable({
providedIn: 'root',
})
export class ShoppingCartStoreService {
readonly #items = signal<ShoppingCartItem[]>(INITIAL_ITEMS);
readonly #customerDetails = signal<CustomerDetails>(INITAL_CUSTOMER_DETAILS);
readonly #discount = signal(0);
readonly #totalPrice = computed(() => {
return this.#items().reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
});
}
Stavke korpe, kupčevi podaci i popust su redovni Writable Signals (Signali koje možemo
menjati). Primećujete privatni prefiks "#"! On odlično funkcioniše. Totalna cena se odmah
računa dinamično preko computed funkcije.
Možemo ove store elemente izlagati napolje kao read-only:
// 1st pristup asReadonly()
readonly customerDetails = this.#customerDetails.asReadonly();
// 2nd pristup: spread operator uz computed()
readonly customerDetails = computed(() => ({...this.#customerDetails()}));
Forma sa Detaljima Korisnika
Kreiraćemo je kako bismo simulirali neku logiku za submit korisnika.
ng generate component components/customer-details-form
@Component({
selector: 'app-customer-details-form',
imports: [FormsModule],
templateUrl: './customer-details-form.component.html',
})
export class CustomerDetailsFormComponent {
readonly customer = input.required<CustomerDetails>();
readonly submit = output<CustomerDetails>();
}
Uz ngModel za template formu i dvosmernu vezu ka podacima bićemo spremni.
Metode Unutar Injektabilnog Store-a
Najpre je bitno implementirati dodavanje u korpu:
addItem(product: Product, quantity: number = 1) {
this.#items.update((items: ShoppingCartItem[]) => {
const index = items.findIndex((item) => item.product.id === product.id);
if (index > -1) {
const updatedItems = [...items];
const existingItem = updatedItems[index];
updatedItems[index] = {
...existingItem,
quantity: existingItem.quantity + quantity,
};
return updatedItems;
}
return [...items, { product, quantity }];
});
}
Kada ga inkorporiramo u glavnu AppComponent komponentu pozivom ove metode
omogućavamo naš store sistem!
Detalji Narudžbine (Shopping Cart Details)
Završavamo celinu kreiranjem sumarnog pregleda onoga što korisnik kupuje.
@Component({
selector: 'app-shopping-cart-details',
imports: [CurrencyPipe],
templateUrl: './shopping-cart-details.component.html',
styleUrl: './shopping-cart-details.component.css',
})
export class ShoppingCartDetailsComponent {
readonly shoppingCart = input.required<ShoppingCartStore>();
protected readonly fullName = computed(() => {
// ... spajamo ime i prezime
});
}
LinkedSignal i Popusti
Ako želimo popust dugme koje važi samo jednokratko. linkedSignal dolazi u
pomoć!
export class ShoppingCartStoreService {
readonly #totalPrice = linkedSignal(() => {
return this.#items().reduce( // Oslanja se na items
(sum, item) => sum + item.product.price * item.quantity, 0
);
});
applyDiscount() {
if (this.#discount() === 0) {
const discount = Math.floor(Math.random() * 21);
this.#discount.set(discount);
// Ručno prepisuje totalPrice iz reaktivnog linked formata unutar sebe
this.#totalPrice.update((value) => (value * (100 - discount)) / 100);
}
}
}
Kao što vidite, Linked Signal je savršen kada imamo kalkulaciju, a isto dozvoljavamo i overwritovanje nezavisno u runtime-u!
Efekti (Effects)
Efekti se ne zloupotrebljavaju. Primer je usklađivanje vrednosti u localStorage-u:
effect(() => {
const customerDetails = this.#customerDetails();
localStorage.setItem(
CUSTOMER_DETAILS_STORAGE_KEY,
JSON.stringify(customerDetails)
);
});
Zaključak
Angular Signal Stores nudi laganu, kontrolisanu alternativu gigantskim state management sistemima za reaktivni razvoj stabilnih Web Aplikacija!