Angular in Practice #3: Product CRUD with Forms + API

10 min read

In #2, we wrapped up the login/logout flow and the auth guard. We laid down the path where the token rides on the interceptor to the server, so now it’s time to actually work with data. In this post, we build the screens that can CRUD — create, read, update, delete — our app’s core domain, Product.

This post draws on quite a few tools. The form model from Intermediate #1 Reactive Forms, data preloading via Intermediate #6 Resolver, and HttpClient and Signals from the basics. Think of it as practice in combining the tools you’ve already learned into one screen.

Backend API contract #

Before writing code, let’s lock in the shape we’ll exchange with the server. Since we don’t build a backend in this practice series, we assume the mock server stood up in #1 provides the following five endpoints.

MethodPathDescription
GET/api/productsList
POST/api/productsCreate
GET/api/products/:idSingle read
PATCH/api/products/:idPartial update
DELETE/api/products/:idDelete

The response shape is simple.

src/app/products/product.model.ts
export interface Product {
  id: number;
  name: string;
  price: number;
  category: 'food' | 'drink' | 'goods';
  description: string;
  createdAt: string;
}

export type NewProduct = Omit<Product, 'id' | 'createdAt'>;
export type ProductPatch = Partial<NewProduct>;

Product is the complete shape the server returns, NewProduct is what you send when creating (the server attaches id and createdAt), and ProductPatch is the partial object you send on update. Splitting types into these three reduces confusion about what each screen handles.

ProductService — four methods #

Start with the service class. Every method returns an Observable, and the caller subscribes or converts via toSignal as needed.

src/app/products/product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private base = '/api/products';

  list() { return this.http.get<Product[]>(this.base); }
  get(id: number) { return this.http.get<Product>(`${this.base}/${id}`); }
  create(payload: NewProduct) { return this.http.post<Product>(this.base, payload); }
  update(id: number, patch: ProductPatch) {
    return this.http.patch<Product>(`${this.base}/${id}`, patch);
  }
  remove(id: number) { return this.http.delete<void>(`${this.base}/${id}`); }
}

@Injectable({ providedIn: 'root' }) makes it a single instance app-wide. We deliberately keep the service free of transformation logic beyond network calls — letting each calling component shape the data to its own needs improves reusability.

Product list screen #

The list is the simplest pattern. As soon as the component spins up, call list(), take the result through toSignal, and render with @for.

src/app/pages/product-list.component.ts
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [RouterLink],
  templateUrl: './product-list.component.html',
})
export class ProductListComponent {
  private products = inject(ProductService);

  protected readonly state = toSignal(
    this.products.list().pipe(catchError(() => of(null))),
    { initialValue: 'loading' as const },
  );
}

toSignal is the bridge that converts an Observable into a Signal. The initial value is the string 'loading', on success it becomes the array, and on error it becomes null. As a result, state() ends up as one of three values — 'loading' / Product[] / null. Branch on those three in the template.

src/app/pages/product-list.component.html
<header>
  <h1>Products</h1>
  <a routerLink="/products/new">+ Add product</a>
</header>

@if (state() === 'loading') {
  <p>Loading...</p>
} @else if (state() === null) {
  <p class="error">Failed to load products. Please try again later.</p>
} @else if (state()!.length === 0) {
  <p class="empty">No products registered yet.</p>
} @else {
  <ul class="grid">
    @for (p of state(); track p.id) {
      <li class="card">
        <a [routerLink]="['/products', p.id]">
          <h3>{{ p.name }}</h3>
          <p>{{ p.price | number }} KRW</p>
        </a>
      </li>
    }
  </ul>
}

Loading, error, empty, and normal — four branches expressed in a single @if / @else if / @else chain. You can see at a glance which state each branch represents. Handling the empty state separately may seem trivial, but it has a big impact on usability — don’t leave first-time users staring at a blank screen with no explanation.

Tip
The reason we set the initial value to 'loading' is to keep the Signal from being empty at the first render. By default, toSignal returns undefined until the Observable emits its first value, which means you’d have to handle undefined as another branch in the template. Using an explicit sentinel value (here, the string 'loading') keeps the branches clean.

Product detail — pre-fetching with a Resolver #

The detail screen pairs well with the Resolver pattern. Having data in hand from the moment the screen draws means you don’t need a “no data” branch.

src/app/products/product.resolver.ts
export const productResolver: ResolveFn<Product | null> = (route) => {
  const id = Number(route.paramMap.get('id'));
  const router = inject(Router);

  return inject(ProductService).get(id).pipe(
    catchError(() => {
      router.navigate(['/products']);
      return of(null);
    }),
  );
};

If the user enters a non-existent id, send them back to the list and emit null. Register resolve: { product: productResolver } as a key-value pair on the route, and lazy-load the detail component.

The component pulls the result from ActivatedRoute’s data.

src/app/pages/product-detail.component.ts
@Component({
  selector: 'app-product-detail',
  standalone: true,
  imports: [RouterLink],
  templateUrl: './product-detail.component.html',
})
export class ProductDetailComponent {
  private route = inject(ActivatedRoute);
  protected readonly product = this.route.snapshot.data['product'] as Product;
}

In the template, you bind fields like product.name and product.price directly. Because product is always filled, no optional chaining (?.) or loading branches are needed. The intrinsic appeal of the Resolver shows up here — the component can start without doubting its data.

Add product — Reactive Forms #

The create screen starts with an empty form. We bring the Reactive Forms pattern from Intermediate #1 over directly.

src/app/pages/product-create.component.ts
@Component({
  selector: 'app-product-create',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './product-create.component.html',
})
export class ProductCreateComponent {
  private fb = inject(FormBuilder);
  private products = inject(ProductService);
  private router = inject(Router);

  protected submitting = signal(false);
  protected errorMessage = signal<string | null>(null);

  protected form = this.fb.nonNullable.group({
    name: ['', [Validators.required, Validators.maxLength(80)]],
    price: [0, [Validators.required, Validators.min(0)]],
    category: ['goods' as 'food' | 'drink' | 'goods', Validators.required],
    description: ['', Validators.maxLength(500)],
  });

  onSubmit() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }

    this.submitting.set(true);
    this.errorMessage.set(null);

    this.products.create(this.form.getRawValue()).subscribe({
      next: created => this.router.navigate(['/products', created.id]),
      error: () => {
        this.errorMessage.set('Failed to save. Please try again.');
        this.submitting.set(false);
      },
    });
  }
}

Three points to call out.

  • fb.nonNullable.group — forces every control to be non-null. The type of getRawValue() becomes clean enough to pass straight to the server call as the payload.
  • markAllAsTouched() — at submit time, marks all fields as touched so missing-field errors show up at once.
  • submitting / errorMessage signals — the component itself holds the progress of async work. The loading spinner and error notice both render off these two signals alone.
src/app/pages/product-create.component.html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <label>
    Name
    <input formControlName="name" />
    @if (form.controls.name.touched && form.controls.name.invalid) {
      <small class="error">Enter a name (up to 80 characters).</small>
    }
  </label>

  <label>Price <input type="number" formControlName="price" min="0" /></label>

  <label>
    Category
    <select formControlName="category">
      <option value="goods">Goods</option>
      <option value="food">Food</option>
      <option value="drink">Drink</option>
    </select>
  </label>

  <label>Description <textarea formControlName="description" rows="4"></textarea></label>

  @if (errorMessage()) { <p class="error">{{ errorMessage() }}</p> }

  <button type="submit" [disabled]="submitting()">
    {{ submitting() ? 'Saving...' : 'Save' }}
  </button>
</form>

The pattern of showing validation messages only when touched && invalid — natural UX that keeps red messages from blanketing the screen on first render.

Edit product — filling the form with patchValue #

The edit screen is almost the same as create, except that the form starts pre-filled with existing data and we PATCH only changed fields to the server. The route and Resolver reuse the ones from the detail screen — just register resolve: { product: productResolver } the same way at path: 'products/:id/edit'.

src/app/pages/product-edit.component.ts
export class ProductEditComponent {
  private fb = inject(FormBuilder);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private products = inject(ProductService);

  private original = this.route.snapshot.data['product'] as Product;
  protected submitting = signal(false);

  protected form = this.fb.nonNullable.group({
    name: [this.original.name, [Validators.required, Validators.maxLength(80)]],
    price: [this.original.price, [Validators.required, Validators.min(0)]],
    category: [this.original.category, Validators.required],
    description: [this.original.description, Validators.maxLength(500)],
  });

  onSubmit() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }

    const patch = this.diff();
    if (Object.keys(patch).length === 0) {
      this.router.navigate(['/products', this.original.id]);
      return;
    }

    this.submitting.set(true);
    this.products.update(this.original.id, patch).subscribe({
      next: () => this.router.navigate(['/products', this.original.id]),
      error: () => this.submitting.set(false),
    });
  }

  private diff(): ProductPatch {
    const current = this.form.getRawValue();
    const result: ProductPatch = {};
    (Object.keys(current) as (keyof typeof current)[]).forEach(key => {
      if (current[key] !== this.original[key]) {
        (result as Record<string, unknown>)[key] = current[key];
      }
    });
    return result;
  }
}

The key piece is the diff() method. It compares the form’s current values against the original, key by key, and builds an object containing only the changed fields. The PATCH body becomes lighter, and the server can know exactly which fields changed, which keeps audit logs and concurrency handling clean. If nothing changed, it doesn’t even hit the server and goes straight back to the detail screen.

Note
Sending the entire form value as PATCH does work. But in that case, the distinction between “what the user actually changed” and “the original value re-typed identically” disappears. For systems that record change history, the difference is significant — even when the same value is received again, the server logs an update. Building the habit of sending only changed fields pays off later.

The template is almost identical to create, so we omit it — match formControlName the same way and the form renders pre-filled with the original values without any patchValue call.

Delete — confirm dialog #

Delete is the simplest, but since it’s an irreversible action, an extra “are you sure?” step is warranted. Start with the browser’s built-in confirm, then later swap in a Material dialog.

Add a delete method to the detail component
protected onDelete() {
  if (!confirm(`Delete '${this.product.name}'?`)) return;

  this.products.remove(this.product.id).subscribe({
    next: () => this.router.navigate(['/products']),
    error: () => alert('Failed to delete.'),
  });
}

confirm is the most basic form. In a real project, it’s better to put up a modal with MatDialog and reuse a consistent dialog component — both UX consistency and automated testing benefit.

Optimistic Update #

Picture the delete flow in the list screen. The usual order is “click → DELETE → wait for response (hundreds of ms) → refresh list.” That waiting time feels to the user like “I pressed the button but nothing happened.” Optimistic updates flip the order — change the screen first, tell the server later, and roll back on failure.

src/app/pages/product-list.component.ts (optimistic delete)
export class ProductListComponent {
  private products = inject(ProductService);
  protected items = signal<Product[]>([]);
  // ...initial load logic omitted (toSignal or effect)

  onDelete(target: Product) {
    if (!confirm(`Delete '${target.name}'?`)) return;

    const snapshot = this.items();
    // 1. Change the screen first
    this.items.update(list => list.filter(p => p.id !== target.id));

    // 2. Tell the server
    this.products.remove(target.id).subscribe({
      error: () => {
        // 3. On failure, roll back
        this.items.set(snapshot);
        alert('Failed to delete. Reverted the screen.');
      },
    });
  }
}

The key is the snapshot variable. Hold the state right before the change in hand, and if the server rejects it, restore as-is. From the user’s perspective, 99% of the time the app feels instantly responsive; only that 1% failure produces a brief flicker as it returns to normal.

Optimistic updates aren’t appropriate for everything — actions like payments, where the cost of failure is high, are unsuitable. But for lightweight actions like likes, deletes, and simple toggles, they noticeably boost the perceived speed of the app.

Wrapping up #

In this post, we built Product CRUD screens from start to finish, gathering tools into one place.

  • ProductService — four methods via inject(HttpClient). All return Observables
  • List — convert Observable to Signal with toSignal, four branches (loading, error, empty, normal)
  • Detail — pre-fetch data with productResolver and start with it in hand from the first render
  • Create — form model with fb.nonNullable.group, submit-time validation with markAllAsTouched
  • Edit — Resolver-supplied original as initial values, PATCH only changed fields via diff()
  • Delete — confirm → DELETE → refresh list
  • Optimistic update — change screen first, roll back via snapshot on failure

After all this, one awkwardness remains. Because the list, detail, and edit screens each hold their own local data, a change made on one screen doesn’t automatically reflect in the others. After editing, going back to the list requires another fetch or a manual refresh. The clean way to resolve this is state management.

In the next post, “Angular in Practice #4: State Management — Signals Store and NgRx,” we start with a lightweight Signals-based store and work up to full NgRx — what fits which scale, all in one go. This is where the client architecture portion of the series begins in earnest.

X