Angular IDPStrong Junior
Модулі / Дані, моки й RxJS / Патерн «сервіс-стор»
🔥 0

Модуль 4 · урок 9 з 13

Патерн «сервіс-стор»

Тепер зберемо все в патерн, який ти використовуватимеш до кінця плану. Це те, що в критеріях названо «reactive state streams dynamically feed UI»: сервіс тримає стан, компоненти лише читають і викликають дії.

Три правила патерну

  1. Стан приватний. Усередині BehaviorSubject, назовні — Observable тільки для читання.
  2. Змінювати можна лише методами. Жодного .next() ззовні сервісу.
  3. Похідні дані рахуються, а не зберігаються. Відфільтрований список — це 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 — у шаблоні. Це і є те, що показують на демо.

TASKSTORE _tasks _filters visible$ = combineLatest TASKSPAGE visible$ | async жодного subscribe жодного поля стану жодного markForCheck потоки виклики методів Замінити мок на справжній API — правиш лише TaskApiService. Ні стор, ні компонент цього не помітять. Це і є та «архітектура», яку оцінює лід.
Односторонній потік: стор віддає дані, компонент віддає наміри. Стан живе в одному місці, і його легко тестувати без UI.

Коли потрібен NgRx

Питання, яке точно виникне на дзвінку. Чесна відповідь така: описаного вище патерну вистачає для більшості застосунків, і починати варто з нього. NgRx має сенс, коли:

  • один і той самий стан використовують багато не повʼязаних між собою екранів;
  • потрібна історія дій, undo або відтворення сценаріїв;
  • команда велика й потрібна жорстка домовленість про те, як міняється стан;
  • уже є Redux DevTools у процесі роботи команди.

Для навчального проєкту NgRx буде зайвим шаром, який приховає саме те, що ти маєш зрозуміти.

≈ 55 хв · +25 XP за урок