Complete CRUD Project
This project ties the Angular track together: services, signals, reactive forms, control flow and HTTP-style async work, in one small product manager you can extend into a real app.
On this page · 8 min · Advanced
On this page
- Learning objectives
- Explanation
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 8 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Organise a feature into API, store and components
- Reuse one reactive form for create and edit
- Delete with confirmation and optimistic updates
- Derive filtered views with computed signals
Explanation#
ProductApi wraps the backend (here a fake with delays — replace with HttpClient calls to your ASP.NET Core or Node.js API).
ProductStore owns the state with signals: items, status, editing, query; computed selectors give the filtered list.
ProductForm is a reactive form component with an editing input and a saved output, reused for create and edit.
ProductsPage composes the pieces: search box, table, form. It calls store actions and reads selectors — no business logic of its own.
Next steps: routes for /products/new and /products/:id, guards, an interceptor and tests from the earlier lessons.
Code example: Product manager: API, store, form and page#
import { Component, Injectable, computed, effect, inject, input, output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
export type Product = { id: number; name: string; price: number };
type ProductInput = Omit<Product, 'id'>;
// ── API (replace with HttpClient calls) ──
@Injectable({ providedIn: 'root' })
export class ProductApi {
private db = new Map<number, Product>([[1, { id: 1, name: 'Pen', price: 20 }], [2, { id: 2, name: 'Notebook', price: 60 }]]);
private nextId = 3;
private delay<T>(value: T) { return new Promise<T>(r => setTimeout(() => r(value), 200)); }
list() { return this.delay([...this.db.values()]); }
create(p: ProductInput) { const item = { ...p, id: this.nextId++ }; this.db.set(item.id, item); return this.delay(item); }
update(p: Product) { this.db.set(p.id, p); return this.delay(p); }
remove(id: number) { this.db.delete(id); return this.delay(true); }
}
// ── Store ──
@Injectable({ providedIn: 'root' })
export class ProductStore {
private api = inject(ProductApi);
readonly items = signal<Product[]>([]);
readonly status = signal<'loading' | 'ready' | 'error'>('loading');
readonly editing = signal<Product | null>(null);
readonly query = signal('');
readonly visible = computed(() => {
const q = this.query().toLowerCase();
return this.items().filter(p => p.name.toLowerCase().includes(q));
});
async load() {
try { this.items.set(await this.api.list()); this.status.set('ready'); }
catch { this.status.set('error'); }
}
async save(input: ProductInput) {
const current = this.editing();
if (current) {
const saved = await this.api.update({ ...current, ...input });
this.items.update(list => list.map(p => (p.id === saved.id ? saved : p)));
this.editing.set(null);
} else {
const created = await this.api.create(input);
this.items.update(list => [...list, created]);
}
}
async remove(product: Product) {
const before = this.items();
this.items.update(list => list.filter(p => p.id !== product.id)); // optimistic
try { await this.api.remove(product.id); } catch { this.items.set(before); }
}
}
// ── Form (create + edit) ──
@Component({
selector: 'app-product-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="name" placeholder="Name" />
<input formControlName="price" type="number" placeholder="Price" />
<button [disabled]="form.invalid">{{ editing() ? 'Update' : 'Add' }}</button>
@if (editing()) { <button type="button" (click)="cancelled.emit()">Cancel</button> }
</form>
`,
})
export class ProductForm {
editing = input<Product | null>(null);
saved = output<ProductInput>();
cancelled = output<void>();
form = inject(FormBuilder).nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
price: [0, [Validators.required, Validators.min(1)]],
});
constructor() {
effect(() => { // refill the form when the edited product changes
const p = this.editing();
this.form.reset(p ? { name: p.name, price: p.price } : { name: '', price: 0 });
});
}
submit() {
this.saved.emit(this.form.getRawValue());
this.form.reset();
}
}
// ── Page ──
@Component({
selector: 'app-products-page',
imports: [ProductForm],
template: `
<h1>Products ({{ store.items().length }})</h1>
@switch (store.status()) {
@case ('loading') { <p>Loading…</p> }
@case ('error') { <p role="alert">Could not load products.</p> }
@default {
<input #q (input)="store.query.set(q.value)" placeholder="Search" />
<table>
@for (p of store.visible(); track p.id) {
<tr>
<td>{{ p.name }}</td><td>₹{{ p.price }}</td>
<td><button (click)="store.editing.set(p)">Edit</button> <button (click)="confirmDelete(p)">Delete</button></td>
</tr>
} @empty {
<tr><td colspan="3">No products match.</td></tr>
}
</table>
<h2>{{ store.editing() ? 'Edit ' + store.editing()!.name : 'Add a product' }}</h2>
<app-product-form [editing]="store.editing()" (saved)="store.save($event)" (cancelled)="store.editing.set(null)" />
}
}
`,
})
export class ProductsPage {
protected store = inject(ProductStore);
constructor() { this.store.load(); }
confirmDelete(p: Product) {
if (confirm(`Delete ${p.name}?`)) this.store.remove(p);
}
}
After “Loading…”, a table shows Pen and Notebook with “Products (2)”. Adding “Bag” at 650 appends a row. Edit fills the form (“Edit Pen”) and the button changes to Update; saving updates the row and returns to “Add a product”. Delete asks for confirmation and removes the row immediately. Searching filters the table as you type.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- ProductApi isolates data access; swapping in HttpClient changes nothing else.
- ProductStore holds all state in signals; visible is a computed selector.
- save() decides between create and update based on the editing signal.
- ProductForm is reused for both modes; an effect refills it when editing changes.
- ProductsPage wires inputs and outputs to store actions.
Real-world use case#
Admin modules for products, categories, suppliers and users are all built from this API → store → form/page template, so a new CRUD module takes hours instead of days.
Common mistakes#
If create/update/delete logic lives in the page, it cannot be reused or tested easily. Keep it in the store.
Stale values appear when switching between products. Reset when the edited item changes.
If the API fails, the UI would lie. Restore the previous list on error.
Best practices#
- API → store → components layering
- One form for create and edit
- Computed selectors for filtered views
- Optimistic updates with rollback
Remember this#
API talks to the server; store owns state; components display and emit.
editing signal decides create vs update.
Search = computed filter, no extra request.
Practice exercise#
Your task: Add a "Sort by price" toggle to the store: a sortByPrice signal and update the visible computed to sort ascending when it is true.
Need a hint?
Show solution
import { Injectable, computed, signal } from '@angular/core';
type Product = { id: number; name: string; price: number };
@Injectable({ providedIn: 'root' })
export class SortableStore {
readonly items = signal<Product[]>([{ id: 1, name: 'Notebook', price: 60 }, { id: 2, name: 'Pen', price: 20 }]);
readonly sortByPrice = signal(false);
readonly visible = computed(() => {
const list = this.items();
return this.sortByPrice() ? [...list].sort((a, b) => a.price - b.price) : list;
});
}
Mini quiz#
Where does the create-versus-update decision live in this project?
Want more? Practise all Angular MCQs · Angular interview questions
My notes#
Saved in this browser.