Модуль 4 · урок 9 з 13
Патерн «сервіс-стор»
Тепер зберемо все в патерн, який ти використовуватимеш до кінця плану. Це те, що в критеріях названо «reactive state streams dynamically feed UI»: сервіс тримає стан, компоненти лише читають і викликають дії.
Три правила патерну
- Стан приватний. Усередині
BehaviorSubject, назовні —Observableтільки для читання. - Змінювати можна лише методами. Жодного
.next()ззовні сервісу. - Похідні дані рахуються, а не зберігаються. Відфільтрований список — це
combineLatest, а не ще одне поле стану.
Кістяк
@Injectable({ providedIn: 'root' })
export class TaskStore {
private readonly api = inject(TaskApiService);
// ---- приватний стан ----
private readonly _tasks = new BehaviorSubject<readonly Task[]>([]);
private readonly _loading = new BehaviorSubject(false);
private readonly _error = new BehaviorSubject<string | null>(null);
private readonly _filters = new BehaviorSubject<Filters>({ status: 'all', query: '' });
// ---- публічне читання ----
readonly loading$ = this._loading.asObservable();
readonly error$ = this._error.asObservable();
readonly filters$ = this._filters.asObservable();
// ---- похідне ----
readonly visible$ = combineLatest([this._tasks, this._filters]).pipe(
map(([tasks, f]) => tasks
.filter(t => f.status === 'all' || t.status === f.status)
.filter(t => !f.query || t.title.toLowerCase().includes(f.query.toLowerCase()))
),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly total$ = this.visible$.pipe(map(rows => rows.length));
readonly isEmpty$ = combineLatest([this.visible$, this._loading]).pipe(
map(([rows, loading]) => !loading && rows.length === 0),
);
}
Дії
load(): void {
this._loading.next(true);
this._error.next(null);
this.api.getTasks().pipe(
catchError(err => {
this._error.next(this.humanMessage(err));
return of([]);
}),
finalize(() => this._loading.next(false)),
takeUntilDestroyed(this.destroyRef),
).subscribe(tasks => this._tasks.next(tasks));
}
setStatus(status: TaskStatus | 'all'): void {
this._filters.next({ ...this._filters.value, status });
}
setQuery(query: string): void {
this._filters.next({ ...this._filters.value, query });
}
// оптимістичне оновлення: міняємо одразу, відкочуємо при помилці
updateStatus(id: string, status: TaskStatus): void {
const before = this._tasks.value;
this._tasks.next(before.map(t => t.id === id ? { ...t, status } : t));
this.api.patch(id, { status }).pipe(
catchError(err => {
this._tasks.next(before); // відкат
this._error.next('Не вдалось зберегти');
return EMPTY;
}),
).subscribe();
}
before.map(t => t.id === id ? { ...t, status } : t) — рівно та сама ката
з модуля 3. Незмінені задачі зберігають свої посилання, тож при OnPush
перемалюється одна картка. Тут видно, як модулі складаються один в один.
Компонент стає тонким
@Component({
selector: 'app-tasks-page',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-task-filters
[value]="(store.filters$ | async)!"
(changed)="store.setFilters($event)" />
@if (store.loading$ | async) {
<app-skeleton />
} @else if (store.error$ | async; as error) {
<app-error [message]="error" (retry)="store.load()" />
} @else {
<app-task-table
[rows]="(store.visible$ | async) ?? []"
(statusChanged)="store.updateStatus($event.id, $event.status)" />
}
`,
})
export class TasksPage {
protected readonly store = inject(TaskStore);
constructor() {
this.store.load();
}
}
Зверни увагу: у компоненті немає жодного subscribe, жодного поля стану
й жодного markForCheck. Уся асинхронність — у сервісі, весь UI — у шаблоні.
Це і є те, що показують на демо.
Коли потрібен NgRx
Питання, яке точно виникне на дзвінку. Чесна відповідь така: описаного вище патерну вистачає для більшості застосунків, і починати варто з нього. NgRx має сенс, коли:
- один і той самий стан використовують багато не повʼязаних між собою екранів;
- потрібна історія дій, undo або відтворення сценаріїв;
- команда велика й потрібна жорстка домовленість про те, як міняється стан;
- уже є Redux DevTools у процесі роботи команди.
Для навчального проєкту NgRx буде зайвим шаром, який приховає саме те, що ти маєш зрозуміти.