10 · Project — A Reading List App¶
This project puts every Level 1 lesson into one small, complete application. You will
build a reading list where you can add books, move them between want to read,
reading and done, rate finished books, open a detail page per book, and search the
public Open Library catalogue to add titles. Everything is kept in localStorage, so it
survives a reload.
Every file below was compiled with ng build on Angular 22.2, tested with ng test, and
exercised in headless Chromium. The results of that run are shown in the
Checking it works section.
| Lesson | Where it shows up |
|---|---|
| 02 CLI | ng new, ng build, ng test |
| 03 Templates | bindings and events in every component |
| 04 Signals | the store's signal, computed and effect |
| 05 Control flow | @for with track, @if/@else, @empty |
| 06 Inputs/outputs | BookCard inputs and outputs, StarRating with model() |
| 07 Services | BookStore and OpenLibrary with @Service() |
| 08 Routing | list, detail and search routes; :id bound to an input; lazy chunks |
| 09 HttpClient | the Open Library search |
Step 1 — Create the project¶
Delete the generated src/app/app.html, app.css and app.spec.ts if they exist; the
root component in this project has an inline template. All app code lives under
src/app/reading/.
Step 2 — The model¶
export type Status = 'want' | 'reading' | 'done';
export interface Book {
id: string;
title: string;
author: string;
status: Status;
rating: number; // 0 = not rated
}
rating uses 0 for "not rated" rather than undefined, which keeps the JSON in
localStorage simple and avoids optional-chaining everywhere.
Step 3 — The store¶
All state lives in one service. Components read signals and call methods; only the store writes.
import { Service, computed, effect, signal } from '@angular/core';
import { Book, Status } from './book';
const KEY = 'reading-list';
@Service()
export class BookStore {
private readonly _books = signal<Book[]>(load());
readonly books = this._books.asReadonly();
readonly counts = computed(() => {
const c = { want: 0, reading: 0, done: 0 };
for (const b of this._books()) c[b.status]++;
return c;
});
constructor() {
effect(() => localStorage.setItem(KEY, JSON.stringify(this._books())));
}
byId(id: string) {
return this._books().find((b) => b.id === id);
}
add(title: string, author: string) {
const book: Book = { id: crypto.randomUUID(), title, author, status: 'want', rating: 0 };
this._books.update((list) => [...list, book]);
}
setStatus(id: string, status: Status) {
this.patch(id, { status });
}
rate(id: string, rating: number) {
this.patch(id, { rating });
}
remove(id: string) {
this._books.update((list) => list.filter((b) => b.id !== id));
}
private patch(id: string, changes: Partial<Book>) {
this._books.update((list) => list.map((b) => (b.id === id ? { ...b, ...changes } : b)));
}
}
function load(): Book[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '[]');
} catch {
return [];
}
}
Design notes:
_booksis private and writable;booksis a read-only view. No component can put the list into an invalid state by writing to it directly.countsis acomputed, so the three tallies are recalculated only when the list changes, not on every render.- The
effectin the constructor persists every change. Effects batch, so three quick changes cause one write. - Every update produces a new array and, for patched books, a new object (lesson 04). Mutating in place would leave the UI stale.
load()tolerates corrupted storage by falling back to an empty list — the kind of small defensive choice that saves a user from a blank screen.
Step 4 — A reusable rating control with model()¶
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-star-rating',
template: `
<span role="radiogroup" [attr.aria-label]="label()">
@for (star of stars; track star) {
<button
type="button"
role="radio"
[attr.aria-checked]="star === value()"
[attr.aria-label]="star + ' of 5'"
[class.on]="star <= value()"
(click)="value.set(star === value() ? 0 : star)">★</button>
}
</span>
`,
styles: `
button { border: 0; background: none; font-size: 1.25rem; color: #bbb; cursor: pointer; }
button.on { color: #e0a100; }
`,
})
export class StarRating {
readonly value = model(0);
readonly label = input('Rating');
protected readonly stars = [1, 2, 3, 4, 5];
}
value is a model(), so a parent can use [(value)] or, as below, bind the input and
the valueChange output separately. Clicking the current star again clears the rating.
The radio-group roles and labels give screen-reader users a meaningful control, not five
unlabeled "★" buttons.
Step 5 — The book card¶
import { Component, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Book, Status } from './book';
import { StarRating } from './star-rating';
@Component({
selector: 'app-book-card',
imports: [RouterLink, StarRating],
template: `
<article>
<h3><a [routerLink]="['/books', book().id]">{{ book().title }}</a></h3>
<p>{{ book().author }}</p>
<select [value]="book().status" (change)="statusChange.emit(asStatus($event))" aria-label="Status">
<option value="want">Want to read</option>
<option value="reading">Reading</option>
<option value="done">Done</option>
</select>
@if (book().status === 'done') {
<app-star-rating [value]="book().rating" (valueChange)="rated.emit($event)" />
}
<button type="button" (click)="removed.emit()">Remove</button>
</article>
`,
})
export class BookCard {
readonly book = input.required<Book>();
readonly statusChange = output<Status>();
readonly rated = output<number>();
readonly removed = output<void>();
protected asStatus(event: Event) {
return (event.target as HTMLSelectElement).value as Status;
}
}
The card is presentational: it receives a book and emits intentions
(statusChange, rated, removed). It doesn't know the store exists, which makes it
trivial to reuse and to test with a plain input.
Step 6 — The list page¶
import { Component, computed, inject, signal } from '@angular/core';
import { BookCard } from './book-card';
import { BookStore } from './book-store';
import { Status } from './book';
type Filter = Status | 'all';
@Component({
selector: 'app-book-list',
imports: [BookCard],
template: `
<h2>My books</h2>
<nav aria-label="Filter">
@for (f of filters; track f) {
<button type="button" [attr.aria-pressed]="filter() === f" (click)="filter.set(f)">
{{ f }} @if (f !== 'all') { ({{ store.counts()[f] }}) }
</button>
}
</nav>
<form (submit)="add($event, title.value, author.value); title.value = ''; author.value = ''">
<input #title placeholder="Title" required />
<input #author placeholder="Author" required />
<button>Add</button>
</form>
@for (book of visible(); track book.id) {
<app-book-card
[book]="book"
(statusChange)="store.setStatus(book.id, $event)"
(rated)="store.rate(book.id, $event)"
(removed)="store.remove(book.id)" />
} @empty {
<p>No books here yet.</p>
}
`,
})
export class BookList {
protected readonly store = inject(BookStore);
protected readonly filters: Filter[] = ['all', 'want', 'reading', 'done'];
protected readonly filter = signal<Filter>('all');
protected readonly visible = computed(() => {
const f = this.filter();
const books = this.store.books();
return f === 'all' ? books : books.filter((b) => b.status === f);
});
protected add(event: Event, title: string, author: string) {
event.preventDefault();
if (title.trim() && author.trim()) this.store.add(title.trim(), author.trim());
}
}
visible is derived from two signals — the store's books and the local filter — so
changing either re-renders the list and nothing else. The filter buttons expose their
state with aria-pressed.
Step 7 — The detail page¶
import { Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { BookStore } from './book-store';
@Component({
selector: 'app-book-detail',
imports: [RouterLink],
template: `
@if (book(); as b) {
<h2>{{ b.title }}</h2>
<p>by {{ b.author }} — {{ b.status }}</p>
} @else {
<p>No book with id “{{ id() }}”.</p>
}
<a routerLink="/books">← Back</a>
`,
})
export class BookDetail {
readonly id = input.required<string>(); // filled from the :id route parameter
private readonly store = inject(BookStore);
protected readonly book = computed(() => this.store.byId(this.id()));
}
id is filled from the :id route parameter because the app enables
withComponentInputBinding() (next step). book is a computed over the input and the
store, so it stays correct if the router reuses the component for another id, and it
also updates if the book is edited elsewhere.
Step 8 — Searching Open Library¶
The API client from lesson 09:
import { HttpClient, HttpParams } from '@angular/common/http';
import { Service, inject } from '@angular/core';
import { Observable, map } from 'rxjs';
export interface SearchHit {
key: string;
title: string;
author: string;
year?: number;
}
interface SearchResponse {
numFound: number;
docs: { key: string; title: string; author_name?: string[]; first_publish_year?: number }[];
}
@Service()
export class OpenLibrary {
private readonly http = inject(HttpClient);
search(query: string): Observable<SearchHit[]> {
const params = new HttpParams()
.set('q', query)
.set('fields', 'key,title,author_name,first_publish_year')
.set('limit', 10);
return this.http
.get<SearchResponse>('https://openlibrary.org/search.json', { params })
.pipe(
map((res) =>
res.docs.map((d) => ({
key: d.key,
title: d.title,
author: d.author_name?.[0] ?? 'Unknown',
year: d.first_publish_year,
})),
),
);
}
}
And the search page:
import { Component, inject, signal } from '@angular/core';
import { BookStore } from './book-store';
import { OpenLibrary, SearchHit } from './open-library';
@Component({
selector: 'app-book-search',
template: `
<h2>Find a book</h2>
<form (submit)="$event.preventDefault(); search(q.value)">
<input #q type="search" placeholder="Search Open Library" />
<button [disabled]="loading()">Search</button>
</form>
@if (loading()) {
<p>Searching…</p>
} @else if (error()) {
<p role="alert">{{ error() }}</p>
} @else {
<ul>
@for (hit of results(); track hit.key) {
<li>
{{ hit.title }} — {{ hit.author }} @if (hit.year) { ({{ hit.year }}) }
<button type="button" (click)="store.add(hit.title, hit.author)">Add</button>
</li>
}
</ul>
}
`,
})
export class BookSearch {
private readonly api = inject(OpenLibrary);
protected readonly store = inject(BookStore);
protected readonly results = signal<SearchHit[]>([]);
protected readonly loading = signal(false);
protected readonly error = signal('');
protected search(query: string) {
if (!query.trim()) return;
this.loading.set(true);
this.error.set('');
this.api.search(query).subscribe({
next: (hits) => {
this.results.set(hits);
this.loading.set(false);
},
error: () => {
this.error.set('Search failed. Check your connection and try again.');
this.loading.set(false);
},
});
}
}
Step 9 — Routes, config and the shell¶
import { Routes } from '@angular/router';
import { BookList } from './reading/book-list';
export const routes: Routes = [
{ path: '', redirectTo: 'books', pathMatch: 'full' },
{ path: 'books', component: BookList, title: 'My books' },
{ path: 'books/:id', loadComponent: () => import('./reading/book-detail').then((m) => m.BookDetail), title: 'Book' },
{ path: 'search', loadComponent: () => import('./reading/book-search').then((m) => m.BookSearch), title: 'Search' },
{ path: '**', redirectTo: 'books' },
];
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(),
],
};
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<header>
<h1>Reading List</h1>
<nav>
<a routerLink="/books" routerLinkActive="active">My books</a>
<a routerLink="/search" routerLinkActive="active">Search</a>
</nav>
</header>
<main><router-outlet /></main>
`,
})
export class App {}
Step 10 — Tests¶
import { TestBed } from '@angular/core/testing';
import { BookStore } from './book-store';
describe('BookStore', () => {
beforeEach(() => localStorage.clear());
it('adds books and counts them by status', () => {
const store = TestBed.inject(BookStore);
store.add('Dune', 'Frank Herbert');
store.add('Piranesi', 'Susanna Clarke');
const [dune] = store.books();
store.setStatus(dune.id, 'done');
store.rate(dune.id, 4);
expect(store.counts()).toEqual({ want: 1, reading: 0, done: 1 });
expect(store.byId(dune.id)?.rating).toBe(4);
});
it('persists to localStorage', () => {
const store = TestBed.inject(BookStore);
store.add('Dune', 'Frank Herbert');
TestBed.tick(); // flush the effect
const saved = JSON.parse(localStorage.getItem('reading-list')!);
expect(saved[0].title).toBe('Dune');
});
});
Note TestBed.tick() in the second test: effects run asynchronously, so a test that
checks what an effect did must flush them first. Add the OpenLibrary test from
lesson 09 as src/app/reading/open-library.spec.ts.
Checking it works¶
ng test passed all three tests (the two above plus the lesson 09 API test). ng build
produced an initial bundle of 260.99 kB raw (73.00 kB estimated transfer) with
book-search and book-detail split into lazy chunks of 2.25 kB and 878 bytes.
We then served the production build and drove it with Playwright in headless Chromium: added Dune and Piranesi, set Dune to done, clicked the fourth star, reloaded, opened the detail page, visited a bad id, and searched for "dune". Observed results:
url http://127.0.0.1:4341/books title My books
empty: No books here yet.
cards 2
filters: ['all', 'want (1)', 'reading (0)', 'done (1)']
stored: [{"id":"0b1d56c3-…","title":"Dune","author":"Frank Herbert","status":"done","rating":4},
{"id":"74352b50-…","title":"Piranesi","author":"Susanna Clarke","status":"want","rating":0}]
after reload cards 2
detail: Dune | by Frank Herbert — done | ← Back
missing: No book with id “nope”. title= Book
search first 3: ['Dune — Frank Herbert (1965) Add', 'Dune Messiah — Frank Herbert (1969) Add',
'Children of Dune — Frank Herbert (1976) Add']
console errors []
(The search results come from the live Open Library API and will change over time.)
One thing that run taught us: in the first attempt, the script counted the cards
immediately after clicking Add and got 1 instead of 2. The app was fine — in a
zoneless app, rendering is scheduled after a signal changes rather than happening
synchronously inside the click, and the script checked too early. In tests, use
await fixture.whenStable() or assertions that wait for the DOM, never a check straight
after the event.
How It Actually Works¶
Follow one click — setting Dune to "done" — through the app:
- The
<select>'s nativechangeevent fires. The listener compiled from(change)="statusChange.emit(asStatus($event))"runs insideBookCard. statusChange.emit('done')synchronously calls the listener the parentBookListregistered with(statusChange)="store.setStatus(book.id, $event)".setStatuscallspatch, which calls_books.update(...)with a new array. The signal bumps its version and marks its consumers dirty: thecountsandvisiblecomputeds, the persistence effect, and — through those — the templates ofBookListand eachBookCardthat read them.- Because a template consumer was marked dirty, Angular schedules change detection. When
it runs, it refreshes only views marked for refresh:
BookListre-evaluatesvisible()andstore.counts(); the@forrepeater sees the same track keys, keeps every card, and gives the Dune card its newbookobject; that card's@ifflips on and theStarRatingis created. - The effect queue is flushed in the same cycle, and the effect writes the new list to
localStorage.
Nothing re-renders "the whole app", and no code in the app asked for a render. The signal graph carried the change from the store to exactly the views that depend on it.
Extensions¶
Pick at least two:
- Sort options. Add a sort signal (title, author, rating) and fold it into
visible. - Debounced search. Search as the user types, waiting 300 ms after the last
keystroke. You'll find a clean way to do it in Level 2, lesson 04 (
switchMap), but try it first withsetTimeoutand see what goes wrong with overlapping responses. - Duplicate protection. Stop the same title/author pair from being added twice and show a message instead.
- Detail editing. Let the detail page change status and rating using the same
StarRatingcomponent. - Tests for the card. Render
BookCardwithsetInput('book', ...), click Remove, and assert theremovedoutput fired.
Exercise¶
Without looking back at the code, write down which lesson's concept solves each of these, then check your answers against the table at the top:
- The detail page must update when navigating from one book to another.
- The list must not re-render every row when only one book's rating changes.
- The rating control must be usable with two-way binding.
- The book data must be shared by the list, the detail page and the search page.
- The search page's code should not download until someone visits it.