
Sa najnovijom verzijom Angular-a stiže jedna od najuticajnijih promena u njegovom reaktivnom ekosistemu: Signal Forms - moderan način za rad sa formama vođen signalima u Angular-u.
Godinama smo se oslanjali na Reactive Forms, moćan, ali ponekad opširan API koji je zahtevao puno koda:
FormGroup,FormControl,FormArray- ručne pretplate i čišćenje
- pozivi za
patchValue/setValue - imperativni RxJS tokovi samo za povezivanje vrednosti forme sa HTTP pozivima
Reactive Forms su nam dobro služile, ali kako se Angular razvijao u okvir koji prvo koristi signale, jaz između sinhronog sveta signala i asinhrone mehanike Reactive Forms postao je vidljiviji.
Signal Forms zatvaraju taj jaz na strani stanja forme.
A kada ih kombinujete sa Resource API-jem, dobijate i čist, deklarativni most između:
forma → signali → async HTTP → ponovo stanje signala.
Pre nego što pređemo na "magičnu" verziju, pogledajmo tipičan primer iz stvarnog sveta koristeći klasične Reactive Forms.
🛒 Stvarni Primer: Forma za Pretragu Proizvoda (Reactive Forms)
Zamislite da pravite platformu za e-trgovinu koja podržava pretragu i filtriranje proizvoda na osnovu različitih kriterijuma.
Da bismo se fokusirali, počećemo sa jednim poljem za unos pretrage koje pretražuje javni API.
Odgovor API-ja izgleda ovako:
export interface ProductsResponse {
products: unknown[]; // Product[]
total: number;
skip: number;
limit: number;
}
Jednostavna implementacija Reactive Form bi mogla izgledati ovako:
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, map, startWith, switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgFor, JsonPipe } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [ReactiveFormsModule, NgFor, JsonPipe],
styleUrl: './app.css',
template: `
<main>
<form [formGroup]="form">
<div>
<input
type="text"
formControlName="search"
placeholder="Pretražite proizvode..."
/>
</div>
</form>
<!-- Products list -->
<ul>
<li *ngFor="let product of products()">
{{ product | json }}
</li>
</ul>
</main>
`,
})
export class App implements OnInit {
private readonly http = inject(HttpClient);
private readonly destroyRef = inject(DestroyRef);
protected readonly form = new FormGroup({
search: new FormControl(''),
});
protected readonly products = signal<unknown[]>([]);
ngOnInit(): void {
this.form.valueChanges
.pipe(
startWith(this.form.value),
debounceTime(300),
map(value => ({ q: value.search ?? '' })),
switchMap(params =>
this.http.get<ProductsResponse>(
'https://dummyjson.com/products/search',
{ params },
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: data => {
this.products.set(data.products);
},
error: err => console.error('Greška pri preuzimanju proizvoda:', err),
});
}
}
😅 Šta je "pogrešno" sa ovom slikom?
Ovo radi - ali primetite koliko toga se dešava samo da bi se jedno polje za pretragu povezalo sa HTTP pozivom:
-
Forma živi u svetu Reactive Forms-a (
FormGroup,FormControl). - Logika pretrage je implementirana kao imperativni RxJS tok u
ngOnInit. -
Ručno radimo sledeće:
- očekujemo (debounce) unos,
- gradimo parametre upita,
- prebacujemo se na novi HTTP poziv,
- pretplaćujemo se,
- i guramo rezultat u signal (
products).
-
Potreban nam je
takeUntilDestroyed(this.destroyRef)da bismo izbegli curenje memorije.
Tako završavamo sa tri mentalna modela u jednoj komponenti:
- Reactive Forms API za stanje forme
- RxJS za asinhroni rad i nuspojave
- Signali za stanje prikaza (
products)
Ovo je tačno vrsta "lepak" koda koju su Signal Forms i Resource API dizajnirani da pojednostave.
🔁 Sa Reactive Form na Signal Form
Prelazak sa Reactive Form na Signal Form može se obaviti u nekoliko jasnih koraka:
- Uklonite
ReactiveFormsModuleiz importa. - Uklonite direktivu
[formGroup]sa<form>elementa. - Premestite vaš model forme u objekat zasnovan na signalima.
-
Koristite funkciju
formiz@angular/forms/signalsda biste izgradili Signal Form iz tog modela. -
Koristite direktivu
Fieldiz@angular/forms/signalsda biste povezali vaše unose.
Drugim rečima:
import { Field } from '@angular/forms/signals';
@Component({
selector: 'app-root',
standalone: true,
imports: [Field, JsonPipe, NgFor],
styleUrl: './app.css',
template: `
<main>
<form>
<div>
<input
type="text"
[field]="form.search"
placeholder="Pretražite proizvode..."
/>
</div>
</form>
<!-- Products list -->
<ul>
<li *ngFor="let product of products()">
{{ product | json }}
</li>
</ul>
</main>
`,
})
export class App {
private readonly http = inject(HttpClient);
private readonly model = signal<{ search: string; }>({
search: ''
});
protected readonly form = form<{ search: string; }>(this.model);
// ...
}
U ovom trenutku, refaktorisanje je čisto i minimalno.
Ali sada dolazi zabavni deo:
👉 Kako da zamenimo RxJS tok i pretplatu nečim što se lepo uklapa u Angular-ov model signala?
⚡ Resource API u Pomoć
Želimo da naš Products API reaguje na promene vrednosti forme.
Da, mogli bismo da uradimo nešto poput toObservable(this.model) i da ponovo
izgradimo isti RxJS tok. To bi radilo… ali:
- Gde čuvamo stanje učitavanja?
- Gde prikazujemo greške?
- Kako otkazujemo zahteve u toku kada se pretraga brzo menja?
- Kako da ostanemo unutar grafa signala umesto da dodajemo RxJS sa strane?
Resource API nam pruža jedinstven, deklarativan način da:
- povežemo asinhrone izvore podataka sa signalima
- pratimo stanja učitavanja, uspeha i greške
- automatski se ponovo pokrećemo kada se zavisnosti promene
- otkažemo prethodne zahteve u toku
- ostanemo unutar Angular-ovog reaktivnog grafa
Bez RxJS toka, bez ngOnInit, bez ručnog čišćenja.
Angular vam daje dve vrste Resource-a:
resourceza učitavanje zasnovano na async/Promise-ima-
rxResourceiz@angular/core/rxjs-interopza tokove zasnovane na Observable-ima (kao što jeHttpClient)
import { rxResource } from '@angular/core/rxjs-interop';
protected readonly products = rxResource<ProductsResponse, { search: string }>({
params: () => this.form().value(),
stream: ({ params }) => {
return this.http.get<ProductsResponse>(
'https://dummyjson.com/products/search',
{ params: { q: params.search } },
);
},
});
Šta se ovde dešava?
-
params: () => this.form().value(): Resource prati vrednost Signal Form-e kao zavisnost. Kad god se vrednost forme promeni, resurs se ponovo pokreće. -
stream: {{ '{' }} params {{ '}' }} => this.http.get(…: Za svaki novi skup parametara, vraćamo Observable (naš HTTP poziv).rxResourcese pretplaćuje za nas, obrađuje otkazivanje kada se parametri promene i ažurira svoje unutrašnje stanje signala.
Rezultat je jedan, deklarativni resurs koji možemo koristiti u šablonu kao:
products.value()- trenutni podaciproducts.isLoading()- oznaka učitavanjaproducts.error()- objekat greške
Sve to bez pisanja jednog jedinog subscribe().
✨ "Magična" Verzija: Signal Form + RxResource
Evo pune verzije koja koristi:
- Signal Forms za polje za pretragu
- debounce za korisničko iskustvo
- RxResource za HTTP poziv + stanja učitavanja/greške/vrednosti
- Tailwind samo da bi demo izgledao lepo
import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, effect, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { debounce, Field, form, submit } from '@angular/forms/signals';
export interface ProductsSearchQuery {
search: string;
}
export interface ProductsResponse {
products: unknown[];
total: number;
skip: number;
limit: number;
}
@Component({
selector: 'app-root',
standalone: true,
imports: [Field, JsonPipe],
styleUrl: './app.css',
template: `
<div class="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold text-center text-gray-900 mb-8">
{{ title() }}
</h1>
<!-- Form section -->
<section class="bg-white rounded-lg shadow-lg p-6 mb-8">
<h2 class="text-2xl font-semibold text-gray-800 mb-4 flex items-center">
<svg class="w-6 h-6 mr-2 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Forma za pretragu proizvoda
</h2>
<p class="text-gray-600 mb-4">
Upišite u polje ispod da biste pronašli proizvode. Pretraga se aktivira 300ms nakon kucanja.
</p>
<form (submit)="formSubmit($event)">
<div class="relative mb-4">
<input
type="text"
[field]="form.search"
placeholder="Pretražite proizvode..."
class="w-full px-6 py-4 text-lg border-2 border-gray-300 rounded-lg shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent
transition duration-200"
/>
<svg
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-6 h-6 text-gray-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
@for (error of form.search().errors(); track error.message) {
<div class="text-sm text-red-600 flex items-center gap-1 m-2">
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error.message }}
</div>
}
<button
class="w-full sm:w-auto px-8 py-3 bg-indigo-600 text-white text-base font-semibold rounded-lg
shadow-md hover:bg-indigo-700 hover:shadow-lg focus:outline-none focus:ring-2
focus:ring-indigo-500 focus:ring-offset-2 active:scale-95
transition-all duration-200 flex items-center justify-center gap-2"
type="submit"
[disabled]="form().submitting()"
>
Izaberi
</button>
</form>
</section>
<!-- Results section -->
<section class="bg-white rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-semibold text-gray-800 mb-4 flex items-center">
<svg class="w-6 h-6 mr-2 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Rezultati pretrage
</h2>
@if (products.hasValue()) {
<div class="bg-gray-50 rounded-lg p-4 overflow-auto max-h-96">
<pre class="text-sm text-gray-700 whitespace-pre-wrap">
{{ products.value() | json }}
</pre>
</div>
}
@else {
@if (products.isLoading()) {
<p class="text-gray-500">Učitavanje proizvoda...</p>
}
@else if (products.error()) {
<div class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-red-700 font-medium">Greška pri učitavanju.</p>
</div>
<button
class="ml-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700
focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2
transition duration-200 font-medium"
(click)="products.reload()"
>
Pokušajte ponovo
</button>
</div>
}
}
</section>
</div>
</div>
`,
})
export class App {
protected readonly title = signal('Signal Forms with RxResource');
private readonly http = inject(HttpClient);
private readonly model = signal<ProductsSearchQuery>({
search: '',
});
// 1) Build a Signal Form from the model + debounce
protected readonly form = form<ProductsSearchQuery>(this.model, schema => {
debounce(schema, 300); // debounce form value changes by 300ms
});
// 2) Bind async HTTP to Resource state
protected readonly products = rxResource<ProductsResponse, ProductsSearchQuery>({
params: () => this.form().value(),
stream: ({ params }) =>
this.http.get<ProductsResponse>('https://dummyjson.com/products/search', {
params: { q: params.search },
}),
});
// Optional: Debug effects to inspect form and resource status in devtools
readonly #productsStatusEffect = effect(() => {
console.info('Products Status:');
console.table({
status: this.products.status(),
loading: this.products.isLoading(),
hasValue: this.products.hasValue(),
error: this.products.error(),
});
});
readonly #formStatusEffect = effect(() => {
console.info('Form Status:');
console.table({
valid: this.form().valid(),
invalid: this.form().invalid(),
pending: this.form().pending(),
disabled: this.form().disabled(),
dirty: this.form().dirty(),
touched: this.form().touched(),
submitting: this.form().submitting(),
errors: this.form().errors(),
});
});
protected formSubmit(event: Event): void {
event.preventDefault();
submit(this.form, async () => {
// Just to demo field-level errors:
return [
{
kind: 'error',
field: this.form.search,
message: 'Simulated error on search field.',
},
];
});
console.info('Form Submitted:');
console.table(this.form().value());
}
}
🧠 What Did We Gain?
Compare this with the original Reactive Forms + RxJS version:
-
No
FormGroup,FormControl,valueChanges,takeUntilDestroyed. - No manual subscription, no
ngOnInit. - Debounce is declared at the form level:
debounce(schema, 300). -
rxResource:-
tracks status,
isLoading,error,hasValue - automatically re-runs when params change
- keeps everything in the signal graph
-
tracks status,
Your mental model becomes:
- Signal Form for synchronous form state
- RxResource for asynchronous data state
- Template just reacts to signals and resource status
The code reads almost like a description of the UI behavior.
⚠️ A Note on Experimental APIs
At the time of writing, both:
- Signal Forms, and
- Resource / RxResource
are still marked as experimental / developer-preview in Angular. They’re already very usable, but small API changes may still happen between versions.
That said, the core idea - connect forms → signals → async resources - is here to stay, and it’s already a huge quality-of-life improvement over the classic setup.
✅ When to Reach for Signal Forms + Resource API
You’ll feel the most value when:
- Your forms are tightly coupled with HTTP calls (searches, filters, autocompletes, wizard steps).
- You want to avoid glue code and keep everything inside the signal model.
-
You care about:
- loading/error states in the template,
- proper cancellation of in-flight requests,
- keeping your components small and declarative.
For simple forms that don’t hit APIs, Reactive Forms are still fine.
But once your UI starts dancing with the backend, Signal Forms + Resource API really do feel like magic.