Nazad na blog
10 min čitanja

Signal Forme uz Resource API su Magija

Signal Forme u Angularu
🔍 Kliknite na sliku za uvećanje

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:

  1. Uklonite ReactiveFormsModule iz importa.
  2. Uklonite direktivu [formGroup] sa <form> elementa.
  3. Premestite vaš model forme u objekat zasnovan na signalima.
  4. Koristite funkciju form iz @angular/forms/signals da biste izgradili Signal Form iz tog modela.
  5. Koristite direktivu Field iz @angular/forms/signals da 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:

  • resource za učitavanje zasnovano na async/Promise-ima
  • rxResource iz @angular/core/rxjs-interop za tokove zasnovane na Observable-ima (kao što je HttpClient)
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). rxResource se 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 podaci
  • products.isLoading() - oznaka učitavanja
  • products.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

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.

  • Identifikujte uska grla u performansama
  • Uočite probleme u arhitekturi
  • Dobijte konkretne preporuke
Pokreni Besplatnu Analizu