Курс: Создание веб-приложений с использованием Angular и React

Урок №7

Angular: HTTP, mock API и ошибки

Теория — 60 минут
Практика — 90 минут

Сквозной проект курса: TaskFlow

TaskService соединяется с API через HttpClientКомпонентычитают сигналывызывают методыничего не знают о сетиinjectTaskServiceHttpClient + сигналыloading / error / tasksкэш ответов APIHTTPjson-server :3000GET /tasksPOST /tasksPATCH /tasks/:idDELETE /tasks/:id
Рисунок 1. Слой данных: компоненты видят только сигналы, TaskService — HTTP-запросы к mock API.
Главная идея урока. Урок 6 оставил данные в TaskService внутри кода. Урок 7 переносит источник данных на mock API: TaskService начинает общаться с сервером через HttpClient, а состояние загрузки и ошибок становится частью сервиса.

Студенческий материал. Его можно использовать как самостоятельный конспект, раздаточный материал и справочник после занятия.

Содержание

  1. 1. Что мы построим в этом уроке
  2. 2. Обзор темы: что умеем и что добавим
  3. 3. Клиент-серверная связка в TaskFlow
  4. 4. Что такое mock API и json-server
  5. 5. Структура mock API: коллекции и ресурсы
  6. 6. Основы REST: методы и статусы
  7. 7. HttpClient: что это и зачем
  8. 8. provideHttpClient: включаем HTTP-модуль
  9. 9. Внедрение HttpClient в сервис
  10. 10. Первый запрос: GET /tasks
  11. 11. Observable: поток данных Angular
  12. 12. Promise против Observable: мост из урока 2
  13. 13. pipe, map, subscribe: работаем с потоком
  14. 14. Типизированные ответы и generics
  15. 15. Записываем результат в сигнал
  16. 16. CRUD: POST — создание задачи
  17. 17. CRUD: PATCH — отмечаем выполненной
  18. 18. CRUD: DELETE — удаление задачи
  19. 19. Loading/empty/error: состояния загрузки
  20. 20. Обработка ошибок: catchError и throwError
  21. 21. HTTP-статусы 4xx и 5xx
  22. 22. Интерцептор: логика на пути запросов
  23. 23. Откуда берётся URL: токен API_URL
  24. 24. Переход от локального массива к API
  25. 25. Контракт данных Task при работе с API
  26. 26. Асинхронность и гонки запросов
  27. 27. Практика 1: запускаем json-server
  28. 28. Практика 2: подключаем HttpClient
  29. 28.1. load: загрузка списка с типизацией
  30. 28.2. add / create: POST в сервис
  31. 28.3. toggle: PATCH в сервис
  32. 28.4. remove: DELETE в сервис
  33. 28.5. loading и error в сервисе
  34. 28.6. Практика 3: полный сервис на HTTP
  35. 28.7. Компонент: читаем сигналы
  36. 28.8. Компонент: reactive-ошибка лениво
  37. 28.9. Практика 4: состояния в шаблоне
  38. 28.10. Debug в DevTools/Network
  39. 28.11. Практика 5: полный CRUD-цикл
  40. 28.12. Observable холодный и горячий
  41. 28.13. Собственный ApiClient поверх HttpClient
  42. 28.14. httpOptions и заголовки
  43. 28.15. Синтаксис catchError и работа с ошибкой
  44. 28.16. finally / поляризация UI-состояний
  45. 28.17. Отмена запросов и утечки памяти
  46. 28.18. Сравнение с fetch и async/await
  47. 28.19. Архитектура: слой data и слой transport
  48. 28.20. Сетевые ошибки: сервер не запущен
  49. 28.21. Отладка 404: адрес или данные
  50. 28.22. Имитация медленной сети и задержки
  51. 28.23. Пагинация и query-параметры json-server
  52. 28.24. CORS: почему браузер блокирует запросы
  53. 28.25. Миграция от локального массива к API
  54. 28.26. Кэширование и shareReplay
  55. 28.27. Интерцептор для ошибок
  56. 28.28. Авторизация-заголовки
  57. 28.29. Вложенные данные и коллекции
  58. 28.30. Идемпотентность и повторные попытки
  59. 28.31. Сравнение fetch/Promise-версии
  60. 28.32. Локальный фильтр vs серверный
  61. 28.33. Полный листинг: TaskApi + TaskService
  62. 29. Самостоятельная работа
  63. 30. Подсказки к самостоятельной работе
  64. 31. Частые ошибки HTTP и HttpClient
  65. 32. Резюме: TaskService как клиент API
  66. 33. Контрольные вопросы
  67. 34. Мини-словарь
  68. 35. Источники и продолжение
  69. 36. Чек-лист урока
Урок 7 • Раздел 1

1. Что мы построим в этом уроке

В уроке 6 TaskService держал задачи в сигнале, заполняемом из константы. Теперь данные приходят из настоящего (учебного) сервера. Список загружается по GET, создание — по POST, отметка — по PATCH, удаление — по DELETE. Клиент — HttpClient из Angular.

Контекст. Эта схема — «карта» нашего приложения TaskFlow: кто с кем разговаривает, когда мы нажимаем кнопки.
💡 Аналогия. Представьте ресторан: вы (компонент) сидите за столом, официант (TaskService) бегает на кухню, а кухня (json-server) готовит блюда. Вы не ходите на кухню сами — вы только делаете заказ, а результат приносит официант.
Что увидит пользователь. На экране ничего нового пока нет — это просто схема того, как данные «путешествуют» внутри программы.
Компоненты (сигналы, шаблон)
    │
TaskService (HttpClient + сигналы tasks/loading/error)
    │  HTTP
    ▼
mock API (json-server) на порту 3000

К концу урока TaskFlow работает с сетью: загрузка показывает индикатор, пустой список — заглушку, сбой — сообщение об ошибке.

Итог уровня. Вы видели fetch и async/await в уроке 2. Сегодня — «взрослый» способ Angular: Observable и HttpClient. Обе модели решают одну задачу, мы разберём разницу и научимся читать чужой RxJS-код.
Урок 7 • Раздел 2

2. Обзор темы: что умеем и что добавим

Зафиксируем точку старта.

Умеем (урок 6)Добавим (урок 7)
TaskService с сигналом tasksсигнал наполняется из API
add / toggle / remove в памятиметоды шлют запросы и обновляют кэш
computed-производныеостаются, но зависят от загруженных данных
нет состояния загрузкипоявляются loading / error / empty
параллель с fetch (урок 2)Observable/HttpClient — родной инструмент Angular

Модель Task не изменится: контракт данных тот же. Меняется только то, откуда сервис берёт задачи.

Урок 7 • Раздел 3

3. Клиент-серверная связка в TaskFlow

Angular-приложение — это клиент, работающий в браузере. Оно общается с сервером через HTTP по адресу API. В TaskFlow «сервер» на время курса — локальный mock API.

Контекст. Здесь мы видим, кто кому посылает данные: браузер → сервер → обратно.
💡 Аналогия. HTTP-запрос — это как заказ в магазине: вы посылаете заявку («дайте список товаров»), а в ответ получаете посылку (данные) или отказ (ошибку). Браузер — покупатель, сервер — магазин.
Что увидит пользователь. Стрелки на схеме: запрос уходит на сервер, ответ (JSON) возвращается обратно в приложение.
Браузер (Angular) ── HTTP ──► json-server (localhost:3000)
     ◄── JSON ──

Запросы бывают разные по действию:

Один ресурс — пять действий. Это и есть REST: один адрес (ресурс) + разные методы (действия). Вы уже знакомы с идеей REST из урока 1.
Урок 7 • Раздел 4

4. Что такое mock API и json-server

Mock API — временный сервер, имитирующий настоящий backend. Он отдаёт заранее заготовленные данные и умеет обрабатывать CRUD-запросы. Для курса используем библиотеку json-server.

Контекст. Команда запуска создаёт на вашем компьютере учебный «магазин данных» на порту 3000.
💡 Аналогия. Mock API — это как тренажёр кассы в магазине: он не настоящий, но ведёт себя точно как настоящий сервер, чтобы вы потренировались.
Что увидит пользователь. В терминале появится строка вроде «Resources → http://localhost:3000/tasks». Сервер запущен и ждёт запросов.
# запуск (в папке проекта)
npx json-server db.json --port 3000

Файл db.json описывает данные:

Контекст. Это «меню» нашего учебного сервера: какие задачи в нём лежат в начале.
💡 Аналогия. db.json — как прайс-лист в магазине: сервер читает его и знает, какие «товары» (задачи) у него есть.
Что увидит пользователь. В файле две задачи с id, заголовком, флагом completed и приоритетом. Json-server сам создаст адреса /tasks и /tasks/1 по этим ключам.
{
  "tasks": [
    { "id": 1, "title": "Выучить HttpClient", "completed": false, "priority": "high" },
    { "id": 2, "title": "Подключить loading", "completed": false, "priority": "medium" }
  ]
}

json-server по этому описанию сам предоставляет REST-адреса: /tasks, /tasks/1 и т.д. Имена коллекций берутся из ключей JSON.

Зачем. Настоящий backend и не нужен для изучения HTTP: mock API показывает ту же картину запросов/ответов, а в DevTools вы увидите каждый вызов в Network.
Урок 7 • Раздел 5

5. Структура mock API: коллекции и ресурсы

Каждый ключ верхнего уровня — коллекция (таблица). Каждый элемент — ресурс с полем id.

Контекст. Схема показывает устройство файла db.json: одна коллекция tasks и вложенные в неё задачи.
💡 Аналогия. Коллекция — как папка с набором одинаковых карточек (каждая карточка — ресурс). Поле id — как номер карточки, чтобы её найти.
Что увидит пользователь. Дерево: tasks → список задач, у каждой есть id.
db.json
├── tasks: [ { id, title, completed, priority }, ... ]
└── (позже: users, tags, ...)

json-server автоматически генерирует адреса:

МетодURLОписание
GET/tasksсписок задач
GET/tasks/2одна задача
POST/tasksсоздать (тело JSON)
PATCH/tasks/2обновить часть полей
PUT/tasks/2заменить целиком
DELETE/tasks/2удалить
PATCH vs PUT. PATCH меняет только переданные поля, PUT заменяет ресурс целиком. Для toggle (одно поле) подходит PATCH.
Урок 7 • Раздел 6

6. Основы REST: методы и статусы

HttpClient отправляет HTTP-запрос и получает ответ, который состоит из кода состояния и тела.

Контекст. Любой ответ сервера — это «конверт»: внутри лежат данные, а на конверте — штамп со статусом.
💡 Аналогия. Статус ответа — как ответ продавца в магазине: «есть в наличии, забирайте посылку» (2xx), «такого товара нет» (404) или «вы не в том отделе» (400), «на складе авария, приходите позже» (5xx).
Что увидит пользователь. При успехе — список задач; при 404 — сообщение «не найдено»; при 500 — сообщение «сервер недоступен».
Пользовательский сценарий:
- загрузить задачи  → GET 200 → массив
- неверный id       → GET /tasks/999 → 404
- сервер упал       → 500 → показать ошибку
Почему статусы важны. Именно по ним мы решаем, как обработать ответ: успех — данные, ошибка — сообщение пользователю.
Урок 7 • Раздел 7

7. HttpClient: что это и зачем

HttpClient — сервис Angular для HTTP-запросов. Он внедряется через DI, как любой другой сервис.

Контекст. Это главный помощник, который умеет отправлять запросы на сервер и получать ответы. Без него Angular не умеет «ходить в интернет».
💡 Аналогия. HttpClient — как курьер: вы даёте ему адрес и посылку (запрос), а он едет на склад (сервер), забирает данные и привозит их вам. Вы сами никуда не ходите.
Пошагово: Шаг 1 — подключаем HttpClient из библиотеки. Шаг 2 — делаем его доступным в сервисе через inject(). Шаг 3 — теперь в this.http живёт наш «курьер».
Что увидит пользователь. Пока ничего на экране — это только настройка. Но теперь сервис может слать запросы.
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly http = inject(HttpClient);
}

В отличие от fetch (урок 2), HttpClient возвращает Observable, а не Promise. Поэтому отвечает «потоком», который отдаёт результат подписчику, когда приходит ответ.

Удобство. HttpClient уже умеет превращать ответ в JSON и сообщать об ошибках. Вам не нужно вручную вызывать .json() — всё встроено.
💡 HttpClient против fetch. HttpClient — это обёртка над браузерным fetch: он сам вызывает JSON.parse для ответа, сам бросает ошибку при статусах 4xx/5xx и возвращает Observable вместо Promise. Вам не нужны await и .json(). А provideHttpClient регистрирует всю HTTP-подсистему в DI — без неё inject(HttpClient) упадёт с «No provider for HttpClient».
Урок 7 • Раздел 8

8. provideHttpClient: включаем HTTP-модуль

Чтобы HttpClient был доступен, в корне приложения добавляют провайдер.

Контекст. Провайдер — это «пропуск»: без него Angular не знает, что такое HttpClient.
💡 Аналогия. provideHttpClient() — как открыть дверь офиса курьерской службы. Пока дверь закрыта, курьер (HttpClient) не может войти и работать.
Пошагово: Шаг 1 — импортируем provideHttpClient. Шаг 2 — кладём его в список providers при запуске приложения. Шаг 3 — теперь inject(HttpClient) сработает.
Что увидит пользователь. Без этого блока приложение упадёт с ошибкой «No provider for HttpClient». С ним — всё работает.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app/app';

bootstrapApplication(App, {
  providers: [provideHttpClient()],
});

Без этого вызова inject(HttpClient) упадёт с «No provider for HttpClient».

Запомните. provideHttpClient — современная функция-провайдер. Она «включает» HTTP-подсистему в standalone-приложении.
Урок 7 • Раздел 9

9. Внедрение HttpClient в сервис

Внутри сервиса получаем HttpClient тем же способом, что и другие зависимости.

Контекст. Здесь мы берём «курьера» внутрь нашего сервиса и говорим, по какому адресу бегать.
💡 Аналогия. Это как выдать курьеру карту офиса: base — адрес склада, http — сам курьер. Метод loadTasks() — задание «сходи на склад и принеси задачи».
Пошагово: Шаг 1 — inject(HttpClient) даёт курьера. Шаг 2 — base хранит адрес сервера. Шаг 3 — loadTasks() шлёт GET по адресу base + '/tasks'.
Что увидит пользователь. Пока только настройка — сам запрос уйдёт, когда мы подпишемся (следующий раздел).
@Injectable({ providedIn: 'root' })
export class HttpService {
  private readonly http = inject(HttpClient);
  private readonly base = 'http://localhost:3000';

  loadTasks() {
    return this.http.get<Task[]>(`${this.base}/tasks`);
  }
}

Метод get принимает URL и возвращает Observable, типизированный через дженерик <Task[]>.

Подключаем в TaskService. Логично выделить транспорт в отдельный сервис (ApiClient), а хранилище — в TaskService. Но для начала можно вызывать http прямо в TaskService, как показано в разделе 28.6.
Урок 7 • Раздел 10

10. Первый запрос: GET /tasks

Самый простой запрос — получение списка.

Контекст. Это первый реальный запрос: прочитать все задачи с сервера и положить их в сигнал, который читает экран.
💡 Аналогия. subscribe — как нажать кнопку «позвонить курьеру». Пока не нажали — заказ не отправлен. Курьер поехал только когда вы позвонили.
Пошагово: Шаг 1 — http.get создаёт задание. Шаг 2 — .subscribe(...) реально отправляет запрос. Шаг 3 — когда пришёл ответ, колбэк кладёт данные в сигнал tasks.
Что увидит пользователь. Как только ответ придёт, на экране появится список задач (реактивность урока 5).
readonly tasks = signal<Task[]>([]);

load() {
  this.http.get<Task[]>('http://localhost:3000/tasks')
    .subscribe(tasks => this.tasks.set(tasks));
}

Пока мы только «подписались» на результат. Когда json-server ответит, колбэк положит данные в сигнал, а шаблон перерисуется — это реактивность урока 5.

Разберём по шагам, что происходит в каждой строке:

  1. http.get<Task[]>(url) — создаёт Observable, который «умеет» отправить GET.
  2. .subscribe(tasks => ...) — подписывается: реально отправляет запрос.
  3. Когда приходит ответ, колбэк получает распарсенный JSON как массив Task[].
  4. this.tasks.set(tasks) — кладёт данные в сигнал; шаблон обновляется.
subscribe обязателен. Observable «ленивый»: запрос не уйдёт, пока вы не вызовете subscribe(). Это ключевое отличие от Promise, который стартует сразу.
Урок 7 • Раздел 11

11. Observable: поток данных Angular

Observable — объект, который со временем может выдать ноль, одно или несколько значений. HTTP-ответ выдаёт одно значение (тело ответа), затем завершается.

Контекст. Observable — это «контейнер будущего результата»: вы подписываетесь и когда-нибудь получаете данные.
💡 Аналогия. Observable — как радиоприёмник: вы настраиваетесь (subscribe), и в эфире может прозвучать одна передача (ответ), после чего станция замолкает (завершение).
Что увидит пользователь. После того как данные «прозвучали», они попадают в колбэк и отрисовываются на экране.
http.get(url) ── [данные] ──(завершение)
                       │ subscribe
                       ▼
                 колбэк с данными
Не пугайтесь RxJS. Для HTTP-запросов достаточно трёх вещей: subscribe, pipe с map, обработать ошибку. Всё остальное — опциональное углубление.
Урок 7 • Раздел 12

12. Promise против Observable: мост из урока 2

В уроке 2 вы работали с fetch и Promise. Сравним подходы.

Контекст. Обе технологии решают одну задачу — «подожди ответа из сети». Но пишутся по-разному.
💡 Аналогия. Promise — как заказ с доставкой «подождите у двери, пока привезу» (один раз). Observable — как подписка на газету: можно приходить выпуски снова и снова.
Что увидит пользователь. Результат одинаковый — список задач, но в первом случае через await, во втором — через subscribe.
АспектPromise (fetch)Observable (HttpClient)
Начало работысразупри subscribe
Отменасложнаяunsubscribe
Значенийодноноль или больше
Преобразованиеthenpipe(map)
ОшибкиcatchcatchError + error-колбэк
// fetch (урок 2)
const tasks = await fetch('/tasks').then(r => r.json());

// HttpClient (урок 7)
this.http.get<Task[]>('/tasks').subscribe(t => ...);

Обе модели общаются с одним сервером и дают один результат. Angular предпочитает Observable, потому что они встроены в реактивную систему.

Урок 7 • Раздел 13

13. pipe, map, subscribe: работаем с потоком

Перед подпиской результат можно преобразовать через pipe с операторами.

Контекст. Между получением данных и экраном их можно «причесать»: отфильтровать, посчитать.
💡 Аналогия. pipe(map(...)) — как конвейер на заводе: сначала деталь проходит через одну машину (фильтр), потом через другую (счётчик). Каждый map — отдельная «машина».
Пошагово: Шаг 1 — map оставляет только невыполненные. Шаг 2 — второй map считает их. Шаг 3 — в subscribe попадает уже готовое число.
Что увидит пользователь. В сигнале activeCount окажется, например, «3 активные задачи».
this.http.get<Task[]>(url)
  .pipe(
    map(tasks => tasks.filter(t => !t.completed)), // сколько активных
  )
  .subscribe(tasks => this.activeTasks.set(tasks));

map — как Array.map, но для потока: получает значение, возвращает новое. pipe собирает цепочку операторов в порядке слева направо.

Операторов в цепочке может быть несколько — они выполняются по очереди над значением потока.

this.http.get<Task[]>(url).pipe(
  map(tasks => tasks.filter(t => !t.completed)), // сначала фильтр
  map(active => active.length),                  // затем посчитать
)
.subscribe(count => this.activeCount.set(count));

Сначала берём только активные, затем считаем их количество. Каждый map получает результат предыдущего — как звенья одной цепи.

Аналогия. pipe(map(f), map(g)).then(x => f(x)).then(y => g(y)) в Promise. Просто другой синтаксис той же идеи «преобразуй значение по цепочке».
Урок 7 • Раздел 14

14. Типизированные ответы и generics

HttpClient умеет объявлять тип ответа через дженерик. Компилятор проверит, как вы используете полученные данные.

Контекст. Дженерик <Task[]> говорит TypeScript: «жди массив задач», и подсказки в редакторе работают.
💡 Аналогия. Generics — как этикетка на коробке: «здесь яблоки». Вы не перепутаете с апельсинами и редактор подсветит ошибку, если попытаетесь взять апельсин.
Что увидит пользователь. Ничего визуально — это помощь разработчику, чтобы меньше ошибок в коде.
this.http.get<Task[]>(url)          // массив задач
this.http.get<Task>(`${url}/2`)     // одна задача
this.http.post<Task>(url, draft)    // ответ — созданная задача

Если ответ не соответствует типу, ошибка будет только в рантайме (это не валидация данных, а подсказка типов). Для строгой проверки на время курса достаточно типизации из урока 2.

Контекст. Можно описать сложный ответ: не просто массив, а объект с полями tasks и total.
💡 Аналогия. Как заранее описать форму чека из магазина: «в чеке есть список покупок и итоговая сумма». Программа знает, где что искать.
Что увидит пользователь. Код становится понятнее для редактора; реальная проверка данных — отдельная тема.
// объявим тип ответа явно
interface TaskResponse {
  tasks: Task[];
  total: number;
}
this.http.get<TaskResponse>(url);
Важно. Generics здесь — контракт на уровне TypeScript: они не проверяют фактические данные сети, но защищают код от неверных предположений разработчика.
Урок 7 • Раздел 15

15. Записываем результат в сигнал

Финальный шаг GET — положить данные в сигнал, который читают компоненты.

Контекст. Теперь запрос не просто приходит, а аккуратно раскладывается: данные → в tasks, ошибка → в консоль.
💡 Аналогия. subscribe({ next, error }) — как два окошка в двери: в одно приносят посылку (next), в другое — извещение об отказе (error).
Пошагово: Шаг 1 — в next кладём данные в сигнал. Шаг 2 — в error пишем ошибку в консоль. Шаг 3 — экран сам перерисовывается.
Что увидит пользователь. Список задач на экране (next) или запись об ошибке в консоли (error).
readonly tasks = signal<Task[]>([]);

load(): void {
  this.http.get<Task[]>(this.apiUrl + '/tasks')
    .subscribe({
      next: t => this.tasks.set(t),
      error: e => console.error('Не удалось загрузить', e),
    });
}

Передаём объект с колбэками: next — данные, error — ошибка. Так мы готовимся к состояниям, которые разберём далее.

Цепочка. HTTP → Observable → сигнал → шаблон. Данные «перетекают» от сервера к экрану через один маршрут, и Angular перерисовывает всё автоматически.
Урок 7 • Раздел 16

16. CRUD: POST — создание задачи

Создание отправляет черновик в теле запроса и получает созданную задачу с id.

Контекст. Этот метод отправляет новую задачу на сервер (POST) и добавляет её в список, когда сервер ответит.
💡 Аналогия. POST — как отправить заполненную анкету на склад: вы посылаете черновик, а склад возвращает готовую карточку с номером (id).
Пошагово: Шаг 1 — http.post шлёт черновик. Шаг 2 — сервер присваивает id. Шаг 3 — в next мы добавляем готовую задачу в конец списка через update.
Что увидит пользователь. Новая задача появляется внизу списка сразу после создания.
add(draft: TaskDraft): void {
  this.http.post<Task>(this.apiUrl + '/tasks', draft)
    .subscribe({
      next: created => this.tasks.update(xs => [...xs, created]),
      error: e => this.handleError(e),
    });
}

После успеха добавляем созданную задачу в сигнал неизменяемо (spread, урок 5). id присваивает json-server.

Не менять вручную. Не делайте set с временно сгенерированным id вне сервера: настоящий id приходит в ответе POST.
▶️ POST создания задачи. Аналог на чистом JS/HTML: fetch с method:'POST' и JSON-телом. В Angular то же самое делает http.post<Task>(...) (раздел 16).
Песочница: нажми «Запустить» — и увидишь создание новой задачи через POST (курьер несёт посылку на склад). Меняй код и запускай снова.
Песочница: POST создания задачи (аналог на JS)
Урок 7 • Раздел 17

17. CRUD: PATCH — отмечаем выполненной

Тoggle меняет одно поле. Отправляем объект с этим полем.

toggle(id: number, completed: boolean): void {
  this.http.patch<Task>(`${this.apiUrl}/tasks/${id}`, { completed })
    .subscribe({
      next: updated => this.tasks.update(xs =>
        xs.map(t => t.id === id ? updated : t)),
      error: e => this.handleError(e),
    });
}

В URL подставляем id задачи, в теле — только изменяемое поле. Полученную задачу кладём на место старой.

Иначе. Можно не ждать ответа и обновлять сигнал оптимистично. Но на первом этапе ждём подтверждения сервера — проще и честнее по состоянию.
Урок 7 • Раздел 18

18. CRUD: DELETE — удаление задачи

Удаление отправляет DELETE по адресу задачи.

Контекст. Метод просит сервер удалить задачу, а мы убираем её из списка, когда сервер подтвердит.
💡 Аналогия. DELETE — как выбросить карточку из архива: вы называете номер, и кладовщик выкидывает её. Ответ обычно пустой (коробка пуста).
Пошагово: Шаг 1 — http.delete шлёт запрос по адресу с id. Шаг 2 — сервер удаляет. Шаг 3 — в next мы фильтруем список, убирая задачу с этим id.
Что увидит пользователь. Задача исчезает из списка на экране.
remove(id: number): void {
  // DELETE удаляет ресурс; тело ответа пустое — тип void
  this.http.delete<void>(`${this.apiUrl}/tasks/${id}`)
    .subscribe({
      next: () => this.tasks.update(xs => xs.filter(t => t.id !== id)),  // убираем из списка
      error: e => this.handleError(e),
    });
}

Ответ DELETE обычно пуст (204), поэтому тип <void>. После успеха убираем задачу из сигнала фильтрацией.

Тип ответа. Для пустых ответов используют <void> — читается как «мне не нужны данные ответа».
Урок 7 • Раздел 19

19. Loading/empty/error: состояния загрузки

При работе с сетью состояние не сводится к данным. Есть фазы:

Контекст. Пока данные летят по сети, на экране нельзя просто показать «пусто» — надо понять, что именно происходит.
💡 Аналогия. loading / empty / error — как три состояния посылки: «в пути» (loading), «приехало, но коробка пустая» (empty), «посылка потерялась» (error). Четвёртое состояние — «приехало с товаром» — это сами данные.
Что увидит пользователь. Спиннер загрузки → либо список, либо «задач нет», либо красное сообщение об ошибке.
loading → data (или empty)  →  error

В сервисе заведём сигналы состояния.

Контекст. Реальный метод load(), который зажигает/гасит «табло» состояний и кладёт данные в сигнал.
💡 Аналогия. Как три состояния посылки: пока она «в пути» — горит табло загрузки (loading); когда приехала — либо коробка с товаром (данные), либо посылка потерялась (ошибка).
Пошагово: Шаг 1 — loading.set(true), loadError.set(null). Шаг 2 — отправляем GET. Шаг 3 — в next кладём задачи и гасим loading; в error пишем сообщение и тоже гасим loading.
Что увидит пользователь. Спиннер → список задач, либо сообщение «Не удалось загрузить».
readonly loading = signal(false);
readonly loadError = signal<string | null>(null);

load(): void {
  this.loading.set(true);
  this.loadError.set(null);
  this.http.get<Task[]>(url).subscribe({
    next: t => { this.tasks.set(t); this.loading.set(false); },
    error: e => { this.loadError.set('Не удалось загрузить'); this.loading.set(false); },
  });
}

Обратите внимание: «пусто» — это не отдельный сигнал. Пустота выводится из данных: если tasks пуст и нет ошибки — список пуст. Так мы не держим три конфликтующих флага.

Контекст. Вместо отдельного флага «пусто» мы вычисляем его из других сигналов — это всегда честно и не конфликтует.
💡 Аналогия. computed — как вывод на калькуляторе: вы не набираете «пусто» вручную, оно само получается из формулы «не загружаемся И нет ошибки И задач 0».
Что увидит пользователь. Когда список пришёл пустым и ошибок нет, экран покажет заглушку «Задач пока нет».
// пустота = производное состояние
readonly isEmpty = computed(() => !this.loading() && !this.loadError() && this.tasks().length === 0);
Паттерн всегда готов. Начало запроса → loading=true, error=null. Успех → данные, loading=false. Ошибка → сообщение, loading=false. Это «машина состояний» в духе уроков 1 и 10.
💡 Три состояния UI. Всегда проверяйте в порядке: loading (запрос идёт — показываем индикатор) → error (сбой — сообщение + кнопка «Повторить») → empty (успех, но tasks().length === 0) → данные. «Пустота» не хранится отдельным флагом, она выводится из сигнала задач, поэтому состояний ровно три, а не четыре конфликтующих.
▶️ GET и три состояния UI. Аналог на чистом JS/HTML (в Angular тот же запрос делает HttpClient через ng serve): показывает loading → success → error при работе с публичным API.
Песочница: нажми «Запустить» — и увидишь загрузку списка и три состояния посылки (loading / пусто / данные). Меняй код и запускай снова.
Песочница: GET списка с состояниями (аналог на JS)
Урок 7 • Раздел 20

20. Обработка ошибок: catchError и throwError

RxJS позволяет перехватить ошибку прямо в потоке оператором catchError.

Контекст. Когда сервер вернул ошибку, нам надо поймать её, показать сообщение и не уронить приложение.
💡 Аналогия. catchError — как сетка безопасности под трапецией: если акробат (запрос) упал, сетка его ловит, и зритель (пользователь) видит аккуратное сообщение, а не катастрофу.
Пошагово: Шаг 1 — внутри pipe срабатывает catchError. Шаг 2 — мы записываем понятное сообщение в loadError. Шаг 3 — throwError прокидывает ошибку дальше в error-колбэк, чтобы там погасить loading.
Что увидит пользователь. Вместо «белого экрана» — вежливое сообщение об ошибке и остановка спиннера.
import { catchError, throwError } from 'rxjs';

load(): void {
  this.http.get<Task[]>(url).pipe(      // создаём поток GET
    catchError(err => {                 // ловим сбой внутри pipe
      console.error(err);
      this.loadError.set(this.messageFrom(err));  // показываем сообщение
      return throwError(() => err);   // пробрасываем дальше в error-колбэк
    }),
  ).subscribe({                         // подписка запускает запрос
    next: t => { this.tasks.set(t); },
    error: e => this.loading.set(false),  // завершаем UI-состояние
  });
}

catchError перехватывает сбой, делает побочное действие и возвращает новый поток. throwError создаёт поток, который «упадёт» — так ошибка дойдёт до error-колбэка subscribe.

У subscribe есть три колбэка — по числу исходов потока:

Контекст. Подписка может закончиться тремя способами — и у каждого свой «почтовый ящик».
💡 Аналогия. next — дверь, в которую приносят посылку; error — окошко для отказа; complete — звонок «больше не принесём».
Что увидит пользователь. Обычно хватает next (показать данные) и error (показать сбой); complete для одиночных HTTP-запросов не обязателен.
.subscribe({
  next: d => { /* успех: есть данные */ },
  error: e => { /* сбой: показать сообщение */ },
  complete: () => { /* поток завершился (необязательно) */ },
});

Для единичных HTTP-ответов чаще всего хватает next и error.

Не глотайте молча. Если не обработать ошибку, subscribe без error-колбэка упадёт в консоль. Всегда давайте пользователю объяснение.
▶️ Обработка ошибки. Аналог на чистом JS/HTML: неверный URL → запрос попадает в catch (в Angular — catchError). Запустите и посмотрите, как ловится сбой.
Песочница: нажми «Запустить» — и увидишь, как ловится сбой (сетка безопасности catchError перехватывает падение). Меняй код и запускай снова.
Песочница: обработка ошибки (аналог на JS)
Урок 7 • Раздел 21

21. HTTP-статусы 4xx и 5xx

Чтобы сообщение было точным, полезно смотреть на статус ошибки. HttpErrorResponse хранит его в поле status.

Контекст. По коду ошибки мы выбираем, что именно написать пользователю.
💡 Аналогия. status — как ответ продавца в магазине: «такого товара нет» (404), «не тот отдел» (400), «склад закрыт — приходите позже» (500).
Что увидит пользователь. Понятные фразы: «Задача не найдена», «Сервер недоступен, попробуйте позже».
function messageFrom(err: any): string {
  if (err.status === 404) return 'Задача не найдена';
  if (err.status >= 500) return 'Сервер недоступен, попробуйте позже';
  return 'Произошла ошибка запроса';
}
СтатусСмыслСообщение
400неверный запроспроверьте данные
401/403нет доступанужна авторизация
404не найденоресурс отсутствует
500ошибка сервераповторите позже
Сетевые ошибки. Если сервер не запущен, fetch/HttpClient дают ошибку сети (status 0). Это тоже стоит отдельно сообщать: «сервер не отвечает».
Урок 7 • Раздел 22

22. Интерцептор: логика на пути запросов

Интерцептор — функция, через которую проходят все HTTP-запросы и ответы. Удобно для логов, единых заголовков, обработки ошибок.

Контекст. Интерцептор стоит «на тропинке» каждого запроса и может его тюнить или залогировать.
💡 Аналогия. Интерцептор — как охранник на входе в склад: каждый курьер проходит мимо него, и охранник может записать «кто пошёл» или проверить пропуск.
Что увидит пользователь. Напрямую ничего, но в консоли появятся записи вида «GET http://localhost:3000/tasks».
@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    console.log(req.method, req.url);
    return next.handle(req);
  }
}
// включить в провайдере
provideHttpClient(
  withInterceptors([(req, next) => {
    console.log(req.method, req.url);
    return next.handle(req);
  }]),
)
Обзор. Полноценные интерцепторы — тема для углубления. Сейчас достаточно знать, что весь HTTP проходит через них, и это «точка расширения» приложения.
Урок 7 • Раздел 23

23. Откуда берётся URL: токен API_URL

Адрес API лучше выносить в конфигурацию, а не зашивать в каждый запрос. Используем InjectionToken из урока 6.

Контекст. Вместо того чтобы писать «localhost:3000» в десяти местах, делаем одну «переменную-адрес».
💡 Аналогия. InjectionToken API_URL — как адресная книга: поменяли адрес в одном месте, и все звонки идут по новому. Не надо бегать по всем коду.
Что увидит пользователь. Ничего нового на экране, но переезд на боевой сервер станет в один присест.
export const API_URL = new InjectionToken<string>('API_URL');

// в провайдерах
providers: [
  provideHttpClient(),
  { provide: API_URL, useValue: 'http://localhost:3000' },
]

// в сервисе
private readonly apiUrl = inject(API_URL);

Теперь сменить адрес на прод можно в одном месте.

Порядок. Если на курсе один json-server, можно обойтись константой. Но токен — правильный путь, потому что он отделяет конфигурацию от кода.
Урок 7 • Раздел 24

24. Переход от локального массива к API

Быстрая смена источника данных: константа → HTTP-запрос.

Контекст. Показываем, как малыми правками превратить «локальный список» в «загрузку с сервера».
💡 Аналогия. Как переключить поставщика: раньше брали товар с полки дома (константа), теперь заказываем у поставщика (сервер) — полка та же, только наполняется иначе.
Что увидит пользователь. Экран не меняется, но данные теперь «живые» и общие для всех, кто откроет приложение.
// было (урок 6): сигнал с константой
private readonly tasks = signal<Task[]>(initialTasks);

// стало (урок 7): сигнал наполняется из API
readonly tasks = signal<Task[]>([]);
load(): void { ... this.tasks.set(...) }

Компоненты не заметят изменений: они по-прежнему читают s.tasksRO(). Меняется только «внутренности» сервиса.

Что именно меняется внутри:

Польза границы. Именно поэтому в уроке 6 мы вынесли состояние в сервис: теперь замена источника на сеть ничего не ломает в шаблонах.
Урок 7 • Раздел 25

25. Контракт данных Task при работе с API

Модель Task остаётся контрактом между сервером и клиентом.

Контекст. Интерфейс Task — это «договор»: какие поля у задачи. Сервер и клиент должны говорить на одном языке.
💡 Аналогия. Модель Task — как бланк анкеты: и склад, и вы заполняете одни и те же графы (id, title, completed, priority). Если графы разные — данные «не читаются».
Что увидит пользователь. Если модель совпадает с сервером — задачи отображаются правильно; если нет — поля пустуют или ошибка.
export interface Task {
  readonly id: number;
  title: string;
  completed: boolean;
  priority: Priority;
}

Поля должны совпадать с тем, что отдаёт json-server. Если сервер даёт, например, done вместо completed, стоит выровнять модель или преобразовать ответ.

Контекст. Если имена полей на сервере и в клиенте различаются, ответ надо «перевести» перед записью в сигнал.
💡 Аналогия. Как переводчик: сервер говорит «done», а ваша программа понимает «completed» — map перекладывает одно в другое.
Что увидит пользователь. Задачи корректно показывают статус выполнения, даже если сервер назвал поле иначе.
.pipe(map(task => ({ ...task, completed: task.done })))

Отображение «формы сервера → формы клиента» называют адаптацией ответа. Оно полезно, когда:

Контекст. Ещё один пример «перевода»: берём сырой ответ и собираем из него аккуратный массив задач.
💡 Аналогия. Как упаковщик на складе: из коробки с лишним мусором достаёт только нужные вещи и раскладывает по полочкам.
Что увидит пользователь. На экране — ровно те поля, что нужны, без лишнего шума от сервера.
.pipe(map(raw => raw.map(t => ({ id: t.id, title: t.title, completed: t.done, priority: t.priority }))))
Соглашение. Типизация — не валидация: данные из сети могут не совпасть. На время курса достаточно держать модель и сервер в согласии (см. раздел 14).
Урок 7 • Раздел 26

26. Асинхронность и гонки запросов

Несколько быстрых запросов могут «перегнать» друг друга. Например, два load подряд: ответ может прийти в обратном порядке.

Контекст. Если запустить два запроса подряд, второй может вернуться раньше первого и перезаписать данные неправильно.
💡 Аналогия. Как два курьера из одного магазина: тот, что вышел вторым, может вернуться первым, и вы положите посылку не в ту коробку.
Что увидит пользователь. Редко, но список может «мигнуть» старыми данными. Лечим: сбрасывать loading в начале каждого запроса.
// плохо: две подписки могут перезаписать данные
load();

// лучше: сбрасывать насыщение loading в начале каждого
load(): void {
  this.loading.set(true);
  // ...
}

Для простых CRUD-операций сервис обычно выполняет один запрос за раз. При росте сложности используют отмену (раздел 28.17) или стабилизацию.

Правило. Всегда показывать loading при старте запроса и выключать в next/error. Тогда пользователь не увидит «мёртвый» экран.
Урок 7 • Раздел 27

27. Практика 1: запускаем json-server

Создаём базу и запускаем mock API.

Контекст. Практика: создаём файл db.json и запускаем сервер, чтобы дальше к нему обращаться.
💡 Аналогия. Как открыть учебный склад: наполнили полки тремя товарами и открыли двери для курьеров.
Что увидит пользователь. В браузере по адресу http://localhost:3000/tasks — JSON со списком из трёх задач.
# db.json в корне проекта
{
  "tasks": [
    { "id": 1, "title": "Настроить HTTP", "completed": false, "priority": "high" },
    { "id": 2, "title": "Загрузить список", "completed": true, "priority": "medium" },
    { "id": 3, "title": "Обработать ошибку", "completed": false, "priority": "low" }
  ]
}

# запуск
npx json-server db.json --port 3000

Откройте http://localhost:3000/tasks в браузере — увидите JSON со списком.

Проверка. Сервер также даёт панель-роуты: /tasks/1 покажет одну задачу, POST-запрос в /tasks создаст новую.
Урок 7 • Раздел 28

28. Практика 2: подключаем HttpClient

Включаем HTTP в корне приложения и внедряем его в сервис.

Контекст. Две настройки: открыть «дверь» HttpClient в приложении и взять его в сервис.
💡 Аналогия. Сначала открыли офис курьерской службы (provideHttpClient), потом наняли курьера в нашу фирму (inject в сервисе).
Что увидит пользователь. Ничего на экране пока, но теперь сервис готов слать запросы.
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app/app';

bootstrapApplication(App, {
  providers: [provideHttpClient()],
});
// task.service.ts
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly http = inject(HttpClient);
  private readonly apiUrl = 'http://localhost:3000';
}

После этого все методы сервиса могут делать HTTP-запросы. Ниже — детальная разборка каждого действия.

Контекст. Скелет сервиса: поле-курьер и адрес сервера. Дальше нарастим методы.
💡 Аналогия. Как выдать сотруднику телефон и записную книжку с адресом склада.
Что увидит пользователь. Файл сервиса готов к дополнению методами load/add/toggle/remove.
✍️ Действие. Создайте файл src/app/services/tasks.service.ts с сервисом из раздела 28.6 и подключите HTTP в src/main.ts через provideHttpClient() (раздел 8). HttpClient внедряется одной строкой: private readonly http = inject(HttpClient);
Урок 7 • Раздел 28.1

28.1. load: загрузка списка с типизацией

Полностью типизированная загрузка.

Контекст. Итоговый метод load() из урока: со всеми сигналами состояний и перехватом ошибок.
💡 Аналогия. Полный путь посылки: пока она «в пути» — горит табло, послали курьера; если посылка потерялась — её ловит сетка безопасности, табло гаснет, и либо товар разложен по полкам (данные), либо объявлена потеря (ошибка).
Пошагово: Шаг 1 — loading=true, ошибка=null. Шаг 2 — GET + catchError (ловим сбой). Шаг 3 — в next кладём задачи и гасим loading; в error тоже гасим.
Что увидит пользователь. Спиннер → список или сообщение «Не удалось загрузить задачи».
readonly tasks = signal<Task[]>([]);
readonly loading = signal(false);
readonly loadError = signal<string | null>(null);

load(): void {
  this.loading.set(true);                       // начинаем: показываем индикатор
  this.loadError.set(null);                     // сбрасываем старую ошибку
  this.http.get<Task[]>(this.apiUrl + '/tasks').pipe(  // GET-запрос к API
    catchError(err => {                        // перехват сбоя в потоке
      this.loadError.set('Не удалось загрузить задачи');
      return throwError(() => err);             // пробрасываем дальше в error
    }),
  ).subscribe({                                 // подписка реально отправляет запрос
    next: t => { this.tasks.set(t); this.loading.set(false); },   // успех: данные + выключить loading
    error: () => this.loading.set(false),       // сбой: тоже выключить loading
  });
}

Дженерик <Task[]> гарантирует, что в колбэке next мы работаем с массивом задач.

Урок 7 • Раздел 28.2

28.2. add / create: POST в сервис

Создание задачи с отправкой черновика.

Контекст. Метод add() учебного сервиса: шлём POST и аккуратно добавляем задачу.
💡 Аналогия. Как сдать анкету на склад и дождаться, пока кладовщик вернёт карточку с номером — только с ней мы и добавляем товар в витрину.
Пошагово: Шаг 1 — проверяем, что title не пустой (guard). Шаг 2 — POST шлёт черновик. Шаг 3 — в next добавляем созданную задачу в конец списка.
Что увидит пользователь. Новая задача появляется в списке сразу после ответа сервера.
add(draft: TaskDraft): void {
  const { title, priority } = draft;
  if (!title.trim()) return;                 // guard: не шлём пустой запрос
  // POST создаёт ресурс; тело — те поля, что ждёт сервер
  this.http.post<Task>(this.apiUrl + '/tasks', { title, completed: false, priority })
    .subscribe({
      next: created => this.tasks.update(xs => [...xs, created]),  // id приходит из ответа
      error: e => this.handleError(e),        // централизованная обработка ошибки
    });
}

Сервер присвоит id. Полученную задачу добавляем в конец списка.

Детали, на которые стоит обратить внимание:

Почему не set. Если добавить задачу через set с временным id, а сервер даст другой id, при toggle/remove пути разъедутся. Берём id из ответа сервера — единый источник.
Урок 7 • Раздел 28.3

28.3. toggle: PATCH в сервис

Отмечаем задачу выполненной/активной.

Контекст. Метод toggle() учебного сервиса: PATCH меняет одно поле completed.
💡 Аналогия. Как приклеить стикер «сделано» на конкретную карточку на складе по её номеру.
Пошагово: Шаг 1 — находим задачу по id (guard, если нет — выходим). Шаг 2 — PATCH шлёт противоположное completed. Шаг 3 — в next заменяем элемент на ответ сервера.
Что увидит пользователь. Галочка у задачи переключается после подтверждения сервера.
toggle(id: number): void {
  const task = this.tasks().find(t => t.id === id);
  if (!task) return;                          // guard: нет задачи — не шлём запрос
  // PATCH меняет только переданное поле completed
  this.http.patch<Task>(`${this.apiUrl}/tasks/${id}`, { completed: !task.completed })
    .subscribe({
      next: updated => this.tasks.update(xs =>   // заменяем элемент ответом сервера
        xs.map(t => t.id === id ? updated : t)),
      error: e => this.handleError(e),
    });
}

Находим текущее значение, отправляем противоположное, заменяем в списке ответом сервера.

Шаги операции:

  1. Найти задачу в сигнале по id (find из урока 2).
  2. Если её нет — выйти (guard), не слать запрос.
  3. Отправить PATCH с противоположным completed.
  4. Заменить элемент на обновлённый ответ сервера (map).
Серверное подтверждение. Мы ждём ответ и используем его, а не «переворачиваем» локально до ответа. Так данные на экране точно совпадают с тем, что реально сохранил сервер.
Урок 7 • Раздел 28.4

28.4. remove: DELETE в сервис

Удаление задачи.

Контекст. Метод remove() учебного сервиса: DELETE по адресу задачи.
💡 Аналогия. Как выбросить карточку из архива по номеру — и только после подтверждения кладовщика убрать её из нашей папки.
Пошагово: Шаг 1 — DELETE шлёт запрос. Шаг 2 — сервер удаляет. Шаг 3 — в next фильтруем список, убирая задачу.
Что увидит пользователь. Задача исчезает со списка только если сервер подтвердил удаление.
remove(id: number): void {
  // DELETE удаляет ресурс; тело ответа пустое — тип void
  this.http.delete<void>(`${this.apiUrl}/tasks/${id}`)
    .subscribe({
      next: () => this.tasks.update(xs => xs.filter(t => t.id !== id)),  // убираем из списка
      error: e => this.handleError(e),
    });
}

После серверного удаления убираем id из сигнала. Если сервер вернул ошибку, задача останется — UI не рассинхронизируется с данными.

Пояснение к типам и ожиданиям:

Консистентность. Убирая задачу только после успешного ответа, вы избегаете «фантомов» — элементов, которые исчезли с экрана, но всё ещё хранятся на сервере.
Урок 7 • Раздел 28.5

28.5. loading и error в сервисе

Вынесем вспомогательный обработчик ошибок, чтобы не дублировать.

Контекст. Один метод handleError переводит любую ошибку в понятный текст — не повторяем код в каждом запросе.
💡 Аналогия. Как дежурный переводчик: любая беда (склад закрыт, нет такой карточки, обрыв связи) превращается в вежливую фразу для посетителя.
Пошагово: Шаг 1 — 500 → «Сервер недоступен». Шаг 2 — 404 → «Задача не найдена». Шаг 3 — иначе → «Ошибка запроса» (status 0 = нет сети).
Что увидит пользователь. Всегда понятное сообщение вместо сухого кода ошибки.
private handleError(err: HttpErrorResponse): void {
  this.loading.set(false);
  if (err.status >= 500) {
    this.loadError.set('Сервер недоступен');
  } else if (err.status === 404) {
    this.loadError.set('Задача не найдена');
  } else {
    this.loadError.set(`Ошибка запроса (${err.status || 'сеть'})`);
  }
}

Единая точка превращает любой сбой в понятное пользователю сообщение.

Обратите внимание на порядок проверки:

  1. status >= 500 — серверная ошибка, повторять бессмысленно сейчас.
  2. status === 404 — ресурс не найден.
  3. иначе — общая ошибка; а status === 0 означает «нет сети/сервер не запущен».

Если несколько запросов должны вызывать один и тот же «переключатель загрузки», удобнее не ставить loading.set(false) в каждом колбэке, а воспользоваться finalize (раздел 28.16).

Урок 7 • Раздел 28.6

28.6. Практика 3: полный сервис на HTTP

Собираем все кусочки в один сервис.

Контекст. Итоговый вид TaskService: хранилище (tasks/loading/error), фильтр и методы CRUD в одном месте.
💡 Аналогия. Как собрать всю фирму в одном офисе: склад, диспетчер и телефон под одной крышей.
Что увидит пользователь. Полноценное приложение, которое загружает, фильтрует и показывает задачи.
@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly http = inject(HttpClient);
  private readonly apiUrl = 'http://localhost:3000';

  readonly tasks = signal<Task[]>([]);
  readonly loading = signal(false);
  readonly loadError = signal<string | null>(null);
  private readonly filter = signal<TaskFilter>('all');

  readonly visibleTasks = computed(() => {
    const f = this.filter();
    if (f === 'active') return this.tasks().filter(t => !t.completed);
    if (f === 'done')   return this.tasks().filter(t => t.completed);
    return this.tasks();
  });

  load(): void { ... }
  add(draft: TaskDraft): void { ... }
  toggle(id: number): void { ... }
  remove(id: number): void { ... }
  setFilter(f: TaskFilter): void { this.filter.set(f); }
}

Это и есть TaskService с сетью: хранилище, производные и транспорт в одном месте (дальше можно разделить — раздел 28.13).

Урок 7 • Раздел 28.7

28.7. Компонент: читаем сигналы

Компонент-представление ничего не знает о сети.

Контекст. Компонент только читает сигналы и рисует экран — всю «грязную работу» с сетью делает сервис.
💡 Аналогия. Компонент — как деталь конструктора или запчасть машины: он отвечает только за показ товара на экране, а про то, откуда товар приехал по сети, не знает.
Что увидит пользователь. Четыре ветки @if: спиннер, ошибка, «пусто» или список задач.
@Component({
  selector: 'app-task-list',
  standalone: true,
  template: `
    @if (s.loading()) {
      <div class="loading">Загрузка…</div>
    } @else if (s.loadError()) {
      <div class="error">{{ s.loadError() }}</div>
    } @else if (s.tasks().length === 0) {
      <div class="empty">Задач пока нет</div>
    } @else {
      @for (task of s.visibleTasks(); track task.id) {
        <app-task-item [task]="task" />
      }
    }
  `,
})
export class TaskList {
  readonly s = inject(TaskService);

  ngOnInit() {
    this.s.load();
  }
}
Машина состояний в шаблоне. loading → error → empty → данные. Каждая ветка @if соответствует одному состоянию UI — паттерн из уроков 1 и 10.
Урок 7 • Раздел 28.8

28.8. Компонент: инициализация запроса

Запрос load запускаем один раз при создании компонента — например, в ngOnInit.

Контекст. Ставим «включи загрузку» в момент, когда компонент готов к работе.
💡 Аналогия. Как нажать кнопку «открыть двери» ровно один раз, когда магазин готов принять покупателей.
Что увидит пользователь. Список задач подгружается сразу при открытии экрана.
import { Component, OnInit, inject } from '@angular/core';

export class TaskList implements OnInit {
  ngOnInit() {
    this.s.load();
  }
}

ngOnInit срабатывает после привязки входных свойств — подходящее место для первичной загрузки.

Почему именно здесь:

Не в поле. Поле инициализируется при создании экземпляра; вызов сетевого запроса там — преждевременен. Куда логичнее — lifecycle-хук.
Урок 7 • Раздел 28.9

28.9. Практика 4: состояния в шаблоне

Полный переключатель состояний с кнопкой повтора.

Контекст. Шаблон с четырьмя ветками: загрузка, ошибка (с кнопкой «Повторить»), пусто, список.
💡 Аналогия. Как три состояния посылки: «в пути» (loading — спиннер), «посылка потерялась» (error — «закрыто по техпричине, попробуйте ещё»), «приехало, но коробка пустая» (empty — «пока пусто»), «товар на месте» (данные — «вот задачи»).
Пошагово: Шаг 1 — loading → спиннер. Шаг 2 — loadError → сообщение + кнопка. Шаг 3 — пустой список → заглушка. Шаг 4 — иначе → список.
Что увидит пользователь. Всегда понятный экран, даже при сбое, с возможностью перезапустить загрузку.
@if (s.loading()) {
  <div class="state loading">Загрузка задач…</div>
} @else if (s.loadError()) {
  <div class="state error">
    <p>{{ s.loadError() }}</p>
    <button (click)="s.load()">Повторить</button>
  </div>
} @else if (s.tasks().length === 0) {
  <div class="state empty">Пока нет задач. Добавьте первую!</div>
} @else {
  <app-task-list />
}

Кнопка «Повторить» перезапускает load — удобный приём для сетевых ошибок.

Разберём порядок проверки в шаблоне:

  1. loading — показываем индикатор, чтобы пользователь понял, что идёт работа.
  2. loadError — сбой: сообщение и кнопка «Повторить».
  3. empty — успех, но задач нет: приглашение добавить первую.
  4. иначе — успех и есть данные: рендерим список.

Порядок @else-if задаёт приоритет: загрузка и ошибка важнее пустоты. Так пользователь видит именно актуальное состояние.

Урок 7 • Раздел 28.10

28.10. Debug в DevTools/Network

Вкладка Network браузера показывает каждый HTTP-запрос: метод, URL, статус, время, тело.

Контекст. Главный инструмент отладки: видно, ушёл ли запрос и что вернул сервер.
💡 Аналогия. Network — как камера наблюдения у задней двери склада: вы видите, кто приходил, с чем и что ему ответили.
Что увидит пользователь. В самом приложении ничего; в DevTools (F12) — список всех запросов с цветными статусами.

На что смотреть в каждой строке Network:

ПолеЧто говорит
Nameфактический URL запроса
MethodGET/POST/PATCH/DELETE
Statusкод ответа (200, 404, 500, 0 — сеть)
Payloadтело запроса (для POST/PATCH)
Responseтело ответа (JSON)
Timeсколько занял запрос
Ожидайте. Network — ваш главный инструмент отладки HTTP. Имя, статус и тело запроса отвечают почти на любой вопрос «почему не работает».
Урок 7 • Раздел 28.11

28.11. Практика 5: полный CRUD-цикл

Пройдём сценарий пользователя от начала до конца.

Контекст. Полный «жизненный путь» задачи: загрузили, создали, отметили, удалили.
💡 Аналогия. Как пройти по магазину весь маршрут: зашли → взяли товар → пометили корзину → вышли.
Что увидит пользователь. Каждый шаг = отдельный запрос в Network, и соответствующее изменение на экране.
1. load()             → GET /tasks          → список на экране
2. add(draft)         → POST /tasks         → новая в списке
3. toggle(id)         → PATCH /tasks/:id    → отмечена
4. remove(id)         → DELETE /tasks/:id   → исчезла

Проверьте в Network, что каждый шаг соответствует своему HTTP-методу и URL.

Связь сценариев пользователя (урок 1, раздел 34) и HTTP-операций:

СценарийHTTPСигнал
Увидеть списокGET /taskstasks.set
Создать задачуPOST /tasksдобавить в конец
Отметить выполненнойPATCH /tasks/:idзаменить элемент
Удалить задачуDELETE /tasks/:idотфильтровать
Итог. Это полный CRUD (Create, Read, Update, Delete) по REST через HttpClient. Именно его вы потом повторите на React в уроке 18.
Урок 7 • Раздел 28.12

28.12. Observable холодный и горячий

Холодный Observable создаёт новую «работу» для каждой подписки; горячий — один поток для всех.

Контекст. Каждый subscribe на HTTP-запрос запускает НОВЫЙ запрос — об этом легко споткнуться.
💡 Аналогия. Холодный Observable — как личный курьер: каждый раз, когда вы звоните, едет новый человек. Позвонили дважды — два курьера поехали.
Что увидит пользователь. В Network появятся два одинаковых GET, если подписаться два раза.
// HTTP-запрос: холодный
const obs = this.http.get<Task[]>(url);
obs.subscribe();   // запрос 1
obs.subscribe();   // запрос 2 (ещё один GET)

Два subscribe сделали два GET-запроса. Для одиночного HTTP-ответа это нормально. Для общего потока данных иногда нужен shareReplay, чтобы не дублировать подписки.

Практический вывод. Не подписывайтесь на один HTTP-запрос несколько раз без причины — каждый subscribe = новый запрос. Обычно хватает одного.
Урок 7 • Раздел 28.13

28.13. Собственный ApiClient поверх HttpClient

Чтобы разделить транспорт и хранилище, выделим отдельный сервис-клиент.

Контекст. TaskApi знает только про URL и JSON; TaskService превращает это в сигналы.
💡 Аналогия. TaskApi — водитель с машины (знает дорогу), TaskService — кладовщик (знает, куда положить товар). Они не лезут в дела друг друга.
Что увидит пользователь. Ничего нового на экране, но код чище и проще тестировать.
@Injectable({ providedIn: 'root' })
export class TaskApi {
  private readonly http = inject(HttpClient);
  private readonly apiUrl = inject(API_URL);

  list(): Observable<Task[]> {
    return this.http.get<Task[]>(this.apiUrl + '/tasks');
  }
  create(draft): Observable<Task> {
    return this.http.post<Task>(this.apiUrl + '/tasks', draft);
  }
  update(id, patch): Observable<Task> {
    return this.http.patch<Task>(`${this.apiUrl}/tasks/${id}`, patch);
  }
  delete(id): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/tasks/${id}`);
  }
}

TaskService внедряет TaskApi и превращает Observable в сигналы. Компоненты по-прежнему видят только сигналы.

Разделение. Транспорт (знает URL и JSON) отделён от состояния (кэш, computed, методы). Это чистая архитектура, к которой мы шли с урока 6.
Урок 7 • Раздел 28.14

28.14. httpOptions и заголовки

Иногда запросу нужны дополнительные параметры: заголовки, query-параметры.

Контекст. Заголовки — «конверт» запроса (формат данных), params — уточнения («отсортируй по приоритету»).
💡 Аналогия. HttpHeaders — как пометка на посылке «хрупкое / формат JSON»; HttpParams — как приписка «привези самые важные сверху».
Что увидит пользователь. Сервер вернёт список, отсортированный по приоритету, если передать _sort.
import { HttpHeaders, HttpParams } from '@angular/common/http';

const options = {
  headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
  params: new HttpParams().set('_sort', 'priority'),
};

this.http.get<Task[]>(url, options);

Заголовки описывают запрос (формат, токен), query-параметры — фильтры сервера. json-server понимает _sort, _limit, q и др.

Внутри одного ресурса. Фильтрацию для UI мы делаем в computed; серверные query-фильтры — отдельная тема для больших списков.
Урок 7 • Раздел 28.15

28.15. Синтаксис catchError и работа с ошибкой

Разберём, как catchError сочетается с error-колбэком.

Контекст. catchError ловит сбой внутри pipe, делает побочное действие и прокидывает ошибку дальше.
💡 Аналогия. Сетка безопасности поймала акробата, осмотрела его, и только потом крикнула зрителям «упал!» — чтобы те тоже отреагировали.
Пошагово: Шаг 1 — catchError записывает сообщение. Шаг 2 — throwError прокидывает ошибку. Шаг 3 — error-колбэк гасит loading.
Что увидит пользователь. Понятное сообщение + остановка спиннера.
this.http.get<Task[]>(url).pipe(
  catchError(err => {
    this.loadError.set(this.messageFrom(err));
    return throwError(() => err);   // продолжить как ошибку
  }),
).subscribe({
  next: d => this.tasks.set(d),
  error: e => this.loading.set(false),   // также сработает после catchError
});

catchError выполняет побочное действие (ставит сообщение), а throwError «прокидывает» ошибку в error-колбэк, где завершаем UI-состояние.

Зачем throwError. Если catchError вернёт пустой поток, error-колбэк не вызовется и маркеры состояния могут «застрять». Пробрасывая ошибку, вы сохраняете полную цепочку обработки.
Урок 7 • Раздел 28.16

28.16. finally / поляризация UI-состояний

Действие «в любом случае» (успех или ошибка) удобно положить в finalize.

Контекст. finalize срабатывает при любом финале потока — идеально, чтобы один раз погасить loading.
💡 Аналогия. finalize — как выключатель света в комнате: неважно, ушёл гость или его вывели — свет гаснет в любом случае.
Что увидит пользователь. Спиннер всегда останавливается, даже если что-то пошло не так.
import { catchError, finalize, throwError } from 'rxjs';

load(): void {
  this.loading.set(true);
  this.http.get<Task[]>(url).pipe(
    catchError(err => { this.loadError.set(...); return throwError(() => err); }),
    finalize(() => this.loading.set(false)),
  ).subscribe({
    next: d => this.tasks.set(d),
  });
}

finalize срабатывает при завершении потока в любом исходе — удобно для выключения loading в одном месте.

Один источник. Вместо ручного loading.set(false) в next и error достаточно одного finalize. Меньше дублирования — меньше ошибок.
Урок 7 • Раздел 28.17

28.17. Отмена запросов и утечки памяти

Если компонент уничтожен, подписка на Observable всё ещё жива. Хорошая практика — отписываться.

Контекст. Подписка «держит» поток; если компонент закрыли, а запрос ещё идёт — это утечка.
💡 Аналогия. Как выключить радио, выходя из комнаты: иначе оно будет играть в пустой квартире и тратить батарейку.
Что увидит пользователь. Ничего прямо, но приложение не «течёт» памятью при частом открытии/закрытии экранов.
private sub?: Subscription;

ngOnInit() { this.sub = this.s.load$().subscribe(...); }

ngOnDestroy() { this.sub?.unsubscribe(); }

Для сервисных запросов, которые живут на уровне приложения, отписка нужна реже. Но внутри компонента — важно, чтобы не проводить работу после уничтожения.

Когда беспокоиться. Единичный GET обычно успевает завершиться. Потоки, которые могут идти долго или повторяться, — всегда контролируйте жизненным циклом компонента.
Урок 7 • Раздел 28.18

28.18. Сравнение с fetch и async/await

Сведём наблюдения: тот же запрос двумя способами.

Контекст. Один и тот же результат — список задач — получаем и через fetch(Promise), и через HttpClient(Observable).
💡 Аналогия. Как заказать пиццу по телефону (ждёшь у двери) или по приложению (придёт пуш) — пицца одна, способ разный.
Что увидит пользователь. Одинаковый список на экране; разница только в коде разработчика.
// fetch (урок 2) — асинхронный/ожидающий
async function loadTasks() {
  const res = await fetch('/tasks');
  if (!res.ok) throw new Error(res.status);
  return res.json();
}

// HttpClient — потоковый
loadTasks() {
  return this.http.get<Task[]>('/tasks');
}
Сценарийfetch/PromiseHttpClient/Observable
Простой запросawait + res.json()get().subscribe
Проверка статусаres.ok / res.statusошибка при 4xx/5xx сама
Типизациявручнуюgeneric get<Task[]>

HttpClient «встроен» в Angular и интегрирован с реактивностью. fetch — универсальный и тоже валидный выбор. Понимание обоих — цель курса.

Урок 7 • Раздел 28.19

28.19. Архитектура: слой data и слой transport

Соберём полную схему нашего приложения после урока 7.

Контекст. Трёхслойная архитектура: шаблон → компонент → сервис(хранилище) → ApiClient(транспорт) → сервер.
💡 Аналогия. Как конвейер: посетитель → кассир → кладовщик → водитель → склад. Каждый делает только своё.
Что увидит пользователь. Ничего нового, но код легко расширять: меняешь один слой, не трогая другие.
Шаблон (computed, @if/@for)
   │  читает сигналы
Компоненты
   │  inject
TaskService (хранилище: tasks/loading/error, computed)
   │  вызывает методы API
TaskApi (транспорт: HttpClient, URL, JSON)
   │  HTTP
json-server :3000

Компоненты не знают о сети. TaskService не знает об URL-деталях. TaskApi не знает о шаблоне. Каждый слой отвечает за своё.

Для больших проектов. Такая трёхслойная схема масштабируется: добавляются кэш, отмена запросов, интерцепторы — не трогая компоненты.
Урок 7 • Раздел 28.20

28.20. Сетевые ошибки: сервер не запущен

Когда json-server не запущен или недоступен, браузер не получает никакого HTTP-кода — соединение просто не устанавливается.

Контекст. Особый случай ошибки: status = 0 означает «связи нет вообще», а не «сервер ответил плохо».
💡 Аналогия. Как будто магазин закрыт и даже не открывает дверь: это не «нет такого товара», а «мы до него не дошли».
Что увидит пользователь. Сообщение «Сервер не отвечает. Проверьте, запущен ли json-server».
GET http://localhost:3000/tasks  →  (failed) net::ERR_CONNECTION_REFUSED

HttpClient при этом даёт ошибку с status = 0. Именно это значение отличает «нет связи» от «сервер вернул ошибку».

if (err.status === 0) {
  this.loadError.set('Сервер не отвечает. Проверьте, запущен ли json-server');
}
Полезная привычка. Описывать сетевую ошибку отдельно от HTTP-статусов. Пользователю важно понять: дело в сети или в данных.
Урок 7 • Раздел 28.21

28.21. Отладка 404: адрес или данные

404 «не найдено» бывает по двум причинам:

Контекст. 404 = «по этому адресу ничего нет». Надо понять: неверен адрес или нет такой записи.
💡 Аналогия. Как искать полку: либо вы пришли не в тот отдел (опечатка в URL), либо именно этой карточки нет на складе (нет id).
Что увидит пользователь. Подсказка проверить имя коллекции в db.json и точность адреса.
  1. Неверный URL (описка, не та коллекция).
  2. Нет ресурса с таким id (например, /tasks/999).
GET /tasks        → 200 (список)
GET /task         → 404 (описка — нет коллекции task)
GET /tasks/999    → 404 (нет такой задачи)

Проверьте имя коллекции в db.json и точность URL в коде. В Network видно, по какому фактическому адресу ушёл запрос.

Подсказка. json-server отдаёт 404 и для несуществующей коллекции. Если видите 404 на /tasks — проверьте, что файл db.json содержит ключ tasks.
Урок 7 • Раздел 28.22

28.22. Имитация медленной сети и задержки

Чтобы увидеть состояние loading в действии, добавьте задержку на ответ сервера.

Контекст. Флаг --delay заставляет сервер «думать» указанное время — удобно увидеть спиннер.
💡 Аналогия. Как попросить курьера специально не торопиться, чтобы вы успели заметить, как работает табло «в пути».
Что увидит пользователь. Спиннер «Загрузка…» висит 1 секунду, потом появляется список.
// json-server: задержка 1000 мс на все ответы
npx json-server db.json --port 3000 --delay 1000

С такими настройками spinner «Загрузка…» станет заметным, и вы убедитесь, что блоки @if переключаются верно.

@if (s.loading()) {
  <div class="state loading">Загрузка…</div>
}
Практический приём. Задержка помогает тестировать переходы состояний без реальной медленной сети. Для демонстрации ошибок достаточно остановить сервер.
Урок 7 • Раздел 28.23

28.23. Пагинация и query-параметры json-server

json-server поддерживает параметры «на стороне сервера»: сортировку, лимит, страницы.

Контекст. Вместо того чтобы фильтровать у себя, можно попросить сервер прислать сразу «2-ю страницу по 10».
💡 Аналогия. Как просить кладовщика принести не всё, а только «страницу 2, по 10 штук» или «только красные».
Что увидит пользователь. На больших данных — быстрее и меньше трафика; в TaskFlow пока фильтруем локально.
GET /tasks?_page=1&_limit=10      // первая страница по 10
GET /tasks?_sort=priority&_order=desc
GET /tasks?completed=true          // фильтр по полю
GET /tasks?q=keyword               // поиск по строкам
const params = new HttpParams()
  .set('_sort', 'priority')
  .set('_order', 'desc');

this.http.get<Task[]>(url, { params });

В TaskFlow фильтрацию по статусу мы пока делаем локально через computed (урок 6). Серверные query-параметры пригодятся для больших наборов.

Разница. Локальный фильтр — мгновенный, без запроса. Серверный фильтр — новый GET каждый раз. Выбирайте по размеру данных.
Урок 7 • Раздел 28.24

28.24. CORS: почему браузер блокирует запросы

CORS (Cross-Origin Resource Sharing) — политика браузера, ограничивающая запросы на другой домен/порт.

Контекст. Браузер блокирует запросы между разными «происхождениями» (домен/порт), если сервер не разрешил.
💡 Аналогия. CORS — как пропускной пункт: вы из одного здания (порт 4200), а склад в другом (порт 3000) — нужен пропуск от склада.
Что увидит пользователь. В нашем курсе json-server сам выдаёт пропуск, поэтому всё работает локально.
Angular (localhost:4200)  →  API (localhost:3000)  // другой порт = другой origin

Браузер блокирует ответ, если сервер не разрешает origin. json-server по умолчанию разрешает и добавляет заголовки CORS — поэтому локально всё работает.

Практический вывод. Если в консоли видите ошибку CORS — сервер должен добавить заголовок Access-Control-Allow-Origin. В реальном проекте это настройка backend; на курсе json-server делает это сам.
Урок 7 • Раздел 28.25

28.25. Миграция: от локального массива к API по шагам

Переносим TaskFlow на HTTP без риска сломать интерфейс.

Контекст. Пошаговый план миграции: на каждом шаге приложение остаётся рабочим.
💡 Аналогия. Как переставлять мебель по частям, а не выворачивать квартиру: закончили один шкаф — проверили, что проход свободен.
Что увидит пользователь. На каждом этапе приложение работает; в конце — полноценная работа с сервером.
  1. Запустить json-server и проверить /tasks в браузере.
  2. Включить provideHttpClient и внедрить HttpClient в TaskService.
  3. Добавить метод load(), который заполняет сигнал tasks из GET.
  4. Заменить add/toggle/remove: вместо локального изменения — HTTP + обновление из ответа.
  5. Добавить сигналы loading/loadError и ветки @if в шаблонах.
  6. Убрать константу initialTasks и вызовы локальной инициализации.
Итог. На каждом шаге приложение остаётся рабочим. Такой пошаговый подход — безопасный способ внедрять сеть в существующий код.
Урок 7 • Раздел 28.26

28.26. Кэширование и shareReplay

Если несколько компонентов подписываются на один GET, каждый subscribe сделает новый запрос. Чтобы делить один ответ, используют shareReplay.

Контекст. shareReplay «запоминает» результат первого запроса и раздаёт его всем остальным подписчикам.
💡 Аналогия. Как один курьер привёз пиццу на всю общагу: все жильцы берут из одной коробки, а не зовут 10 курьеров.
Что увидит пользователь. Меньше лишних запросов в Network; данные одинаковы на всех экранах.
import { shareReplay, Observable } from 'rxjs';

private readonly cache$?: Observable<Task[]>;

list(): Observable<Task[]> {
  if (!this.cache$) {
    this.cache$ = this.http.get<Task[]>(url).pipe(
      shareReplay(1),
    );
  }
  return this.cache$;
}

Первый subscribe запускает запрос; остальные получают тот же результат, не обращаясь к сети повторно.

Когда нужно. Для простого TaskFlow достаточно одного подписчика на load(). shareReplay пригодится, когда одно и то же API читают множество экранов.
Урок 7 • Раздел 28.27

28.27. Интерцептор для единой обработки ошибок

Вместо дублирования catchError в каждом методе можно обработать ошибку централизованно через интерцептор.

Контекст. Один интерцептор ловит ошибки всех запросов сразу — не повторяем код.
💡 Аналогия. Как centralised охрана на входе: одна сетка безопасности на всю фирму, а не отдельная у каждого стола.
Что увидит пользователь. То же понятное сообщение об ошибке, но кода меньше и он централизован.
export function apiErrorsInterceptor(req: HttpRequest<unknown>, next: HttpHandler) {
  return next.handle(req).pipe(
    catchError((err) => {
      const msg = err.status === 0
        ? 'Нет связи с сервером'
        : `Ошибка ${err.status}`;
      console.error(msg);
      return throwError(() => err);
    }),
  );
}

bootstrapApplication(App, {
  providers: [provideHttpClient(withInterceptors([apiErrorsInterceptor]))],
});

Теперь любая ошибка HTTP-запроса проходит через наш код, но поток по-прежнему падает, и каждая подписка может обработать её по-своему.

Урок 7 • Раздел 28.28

28.28. Авторизация-заголовки и конфиденциальность

Реальные API часто требуют заголовок авторизации: токен, который показывает, кто вы.

Контекст. Заголовок Authorization — «пропуск» с именем; без него сервер может не пустить.
💡 Аналогия. Как клубная карта: показываете её на входе, и вас узнают. Без карты — «извините, только для своих».
Что увидит пользователь. На курсе json-server карту не спрашивает; для реальных проектов заголовок важен.
const headers = new HttpHeaders({
  'Authorization': 'Bearer ' + token,
});

this.http.get<Task[]>(url, { headers });

Добавлять его в каждый запрос вручную утомительно — удобней через интерцептор, читающий токен из сервиса.

Безопасность. Не храните секреты и токены в коде фронтенда публично. На курсе json-server не требует авторизации, но о заголовках стоит знать для реальных проектов.
Урок 7 • Раздел 28.29

28.29. Работа с вложенными данными и коллекциями

Иногда API отдаёт не массив, а объект-обёртку с метаданными.

Контекст. Ответ может быть вида { items: [...], total: 120, page: 1 } — надо достать нужное поле.
💡 Аналогия. Как посылка в большой коробке: внутри сам товар (items) и наклейки (total, page). Вы достаёте товар, наклейки не нужны.
Что увидит пользователь. На экране — только список задач, метаданные остаются «за кадром».
{
  "items": [ ... ],
  "total": 120,
  "page": 1
}
interface Page<T> {
  items: T[];
  total: number;
  page: number;
}

this.http.get<Page<Task>>(url).pipe(
  map(page => page.items),
).subscribe(items => this.tasks.set(items));

Дженерик помогает описать любую форму ответа. Оператор map достаёт нужную часть перед записью в сигнал.

Урок 7 • Раздел 28.30

28.30. Идемпотентность и повторные попытки

Сетевые запросы могут «теряться». Если результат неизменен при повторении (идемпотентная операция), повторная попытка безопасна.

Контекст. Повторять можно то, что безопасно: чтение (GET). А создание (POST) повторять опасно — будет дубль.
💡 Аналогия. Идемпотентность — как «выключи свет»: нажал один раз или пять — свет всё равно выключен (безопасно). А «закажи пиццу» повторять нельзя — привезут пять.
Что увидит пользователь. Кнопка «Повторить» для чтения безопасна; авто-retry на уроке не используем.
import { retry } from 'rxjs';

this.http.get<Task[]>(url).pipe(
  retry(1),   // одна повторная попытка при сбое
);

GET безопасно повторить. POST/PATCH/DELETE повторять рискованно — можно создать дубликат. Поэтому сначала учимся повторять только чтения.

Кнопка «Повторить». В разделе 28.9 мы сами даём пользователю управление повторением — это проще и честнее автоматического retry на этапе обучения.
Урок 7 • Раздел 28.31

28.31. Сравнение fetch/Promise-версии с нашей

Покажем, как бы выглядел тот же TaskService на fetch, чтобы увидеть разницу (подробно — React-урок 18).

Контекст. Тот же метод loadTasks, но на чистом fetch (Promise) — для сравнения подходов.
💡 Аналогия. Один и тот же ужин, приготовленный на плите (fetch) и в мультиварке (HttpClient) — рецепт разный, еда та же.
Что увидит пользователь. Результат идентичный; разница лишь в том, как написан код разработчика.
// fetch-версия (Promise)
async loadTasks() {
  const res = await fetch('/tasks');
  if (!res.ok) throw new Error('HTTP ' + res.status);
  this.tasks.set(await res.json());
}

// HttpClient-версия (Observable)
loadTasks() {
  return this.http.get<Task[]>('/tasks')
    .subscribe(t => this.tasks.set(t));
}

Обе решают одну задачу. Разница в интеграции: Observable вписан в реактивную систему Angular, Promise — универсальный стандарт JS.

Урок 7 • Раздел 28.32

28.32. Почему сначала local computed, а не серверный фильтр

В TaskFlow фильтрация «все/активные/выполненные» остаётся локальной — computed в сервисе. Почему?

Контекст. Маленький список быстрее отфильтровать в памяти, чем слать новый запрос на сервер.
💡 Аналогия. Искать нужную вещь в своём маленьком ящике (локально) быстрее, чем снова ехать на склад (сервер).
Что увидит пользователь. Переключение фильтра мгновенное, без спиннера и запросов.

Серверный фильтр (query-параметры) имел бы смысл для тысяч задач, когда скачивать всё дорого. Пока — локально.

Решение по размеру. Сначала локальный рендер, серверная фильтрация — только при реальной необходимости (объёмы, поиск по большой базе).
Урок 7 • Раздел 28.33

28.33. Полный листинг: TaskApi + TaskService вместе

Соберём типовую связку двух сервисов, глядя на неё целиком.

Контекст. Эталонная пара: TaskApi (транспорт) + TaskService (хранилище). Компоненты видят только второй.
💡 Аналогия. Как два цеха: цех А упаковывает посылки (ApiClient), цех Б раскладывает их по полкам (Service). Магазин снаружи видит только полки.
Что увидит пользователь. Полноценное приложение с чётким разделением ответственности в коде.
// task-api.ts — транспорт
@Injectable({ providedIn: 'root' })
export class TaskApi {
  private readonly http = inject(HttpClient);
  private readonly url = inject(API_URL) + '/tasks';
  list()         { return this.http.get<Task[]>(this.url); }
  create(d)      { return this.http.post<Task>(this.url, d) }
  patch(id, p)   { return this.http.patch<Task>(`${this.url}/${id}`, p); }
  delete(id)     { return this.http.delete<void>(`${this.url}/${id}`); }
}

// task.service.ts — хранилище
@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly api = inject(TaskApi);
  readonly tasks = signal<Task[]>([]);
  readonly loading = signal(false);
  readonly loadError = signal<string | null>(null);

  load() {
    this.loading.set(true);
    this.api.list().pipe(
      catchError(e => { this.loadError.set(this.msg(e)); return throwError(() => e); }),
      finalize(() => this.loading.set(false)),
    ).subscribe(t => this.tasks.set(t));
  }
  // add / toggle / remove — аналогично через this.api
}

Компоненты видят только TaskService и его сигналы. Эта структура станет эталоном и для других курсовых сценариев.

Урок 7 • Раздел 29

29. Самостоятельная работа

Восемь заданий на HTTP и клиента API. Сначала сделайте сами, затем сверьтесь с подсказками.

Задание 1 — Запустить сервер

Создайте db.json с тремя задачами и запустите json-server на порту 3000. Откройте /tasks в браузере.

Задание 2 — Подключить HTTP

Добавьте provideHttpClient в bootstrap и внедрите HttpClient в TaskService.

Задание 3 — Загрузить список

Реализуйте load() с GET и типизацией Task[], сохраняя результат в сигнал.

Задание 4 — Создать задачу

Реализуйте add() через POST, добавьте созданную задачу в список.

Задание 5 — Отметить и удалить

Реализуйте toggle() (PATCH) и remove() (DELETE).

Задание 6 — Состояния загрузки

Добавьте сигналы loading и loadError; покажите их в шаблоне с ветками @if.

Задание 7 — Обработать ошибку

Остановите json-server и нажмите Повторить — убедитесь, что показывается сообщение об ошибке.

Задание 8 — Проверить Network

Пройдите CRUD-цикл и сверьте метод и URL каждого запроса во вкладке Network.

Урок 7 • Раздел 30

30. Подсказки к самостоятельной работе

Совет: сначала сформулируйте идею, затем смотрите код.

Задание 1. npx json-server db.json --port 3000. В браузере откройте http://localhost:3000/tasks.
Задание 2. В bootstrapApplication добавьте providers: [provideHttpClient()]; в сервисе private readonly http = inject(HttpClient).
Задание 3. this.http.get<Task[]>(apiUrl + '/tasks').subscribe(t => this.tasks.set(t)).
Задание 4. POST возвращает созданную задачу: this.tasks.update(xs => [...xs, created]).
Задание 5. PATCH/DELETE с подстановкой id в URL; после успеха map/filter над сигналом.
Задание 6. В начале load — loading.set(true); в next/error — false. В шаблоне ветки @if загружается/пусто/ошибка/список.
Задание 7. При сетевой ошибке err.status равен 0 — сообщение «сервер не отвечает».
Задание 8. Network → фильтр Fetch/XHR покажет GET/POST/PATCH/DELETE по /tasks.
Урок 7 • Раздел 31

31. Частые ошибки HTTP и HttpClient

Соберём типичные проблемы и их решения.

СимптомПричинаРешение
«No provider for HttpClient»нет provideHttpClientдобавить в провайдеры bootstrap
Запрос не уходитнет subscribe (Observable ленивый)подписаться на поток
404 на /tasksсервер не запущен или другой портпроверить Network и запуск json-server
Данные не попали в шаблонсигнал не записан после ответавызвать set/update в next
Ошибка в консолиошибка не обработанадобавить catchError и error-колбэк
loading не выключаетсяset(false) не во всех веткахиспользовать finalize
Тип ответа неверныймодель не совпадает с JSONвыровнять Task с данными сервера
Начните с Network. Большинство проблем HTTP диагностируется вкладкой Network: если запроса нет — клиент, если красный — статус/сервер.
Урок 7 • Раздел 32

32. Резюме: TaskService как клиент API

Пять правил работы с HTTP в Angular.

Дополнительные наблюдения из урока:

CRUD через HttpClientCREATEPOST /tasksREADGET /tasksUPDATEPATCH /tasks/:idDELETEDELETE /tasks/:id
Рисунок 2. Полный CRUD TaskFlow поверх mock API.
Урок 7 • Раздел 33

33. Контрольные вопросы

Ответьте своими словами, затем сверьтесь с конспектом.

  1. Что такое mock API и зачем он нужен?
  2. Зачем нужен provideHttpClient?
  3. Чем Observable отличается от Promise (мост из урока 2)?
  4. Что делает subscribe и почему без него запрос не уходит?
  5. Как типизировать ответ GET через дженерик?
  6. Какой метод для создания, обновления, удаления задачи?
  7. Чем PATCH отличается от PUT?
  8. Как показать loading, empty и error в шаблоне?
  9. Что делает catchError и зачем throwError?
  10. Как определить статус ошибки (404, 500, сеть)?
  11. Как разделить транспорт и хранилище (TaskApi vs TaskService)?
  12. Что даёт разделение на слой данных и слой транспорта?
  13. Что означает «холодный» Observable и как это влияет на запросы?
  14. Зачем интерцепторы и как добавить общий лог?
  15. Как отличить сетевую ошибку (status 0) от HTTP-кода?
  16. Когда фильтрацию стоит перенести с сервера на клиента и наоборот?
  17. Что такое CORS и почему json-server его не вызывает?
  18. Зачем нужен finalize вместо ручного выключения loading в двух местах?
Урок 7 • Раздел 34

34. Мини-словарь

ТерминОпределение
HttpClientСервис Angular для HTTP-запросов, возвращает Observable.
provideHttpClientФункция-провайдер, включающая HTTP-подсистему.
ObservableПоток значений во времени; HTTP-ответ — одно значение.
subscribeНачало подписки; «запускает» выполнение потока.
pipe / mapПреобразование значений в цепочке потока.
catchErrorОператор перехвата ошибки в потоке.
throwErrorСоздание потока, который «упадёт» с ошибкой.
finalizeДействие при завершении потока в любом исходе.
CRUDCreate, Read, Update, Delete — четыре операции с данными.
RESTСтиль API: ресурс + метод (GET/POST/PATCH/DELETE).
json-serverMock API на основе JSON-файла (db.json).
HttpErrorResponseОбъект ошибки с полем status и телом ответа.
ИнтерцепторТочка расширения на пути всех HTTP-запросов.
API_URL (InjectionToken)Токен для адреса API, отделяющий конфигурацию.
Холодный ObservableЗапускается заново на каждую подписку (HTTP).
shareReplayОператор для разделения одного результата между подписками.
finalizeДействие при завершении потока в любом исходе.
CORSПолитика браузера, ограничивающая запросы между origin.
HttpParamsОбъект для query-параметров запроса.
HttpHeadersОбъект для заголовков запроса.
HTTP-статусыКод ответа: 2xx успех, 4xx ошибка клиента, 5xx сервер.
Урок 7 • Раздел 35

35. Источники и продолжение

Для самостоятельного углубления:

Рекомендуемый порядок повторения:

  1. Перечитать разделы 28.1–28.6 (полный сервис на HTTP).
  2. Выполнить задания раздела 29 без подсказок.
  3. Отладить каждый запрос через Network и сравнить с ожиданиями.
  4. Проследить архитектурную схему раздела 28.19.
Следующий шаг. В уроке 8 мы добавим формы: создание и редактирование задач через Reactive Forms с валидацией, а TaskService получит методы с API-формой.
Урок 7 • Раздел 36

36. Чек-лист урока

Готовность к уроку 8. Если вы можете выполнить задания раздела 29 и объяснить, как TaskService общается с json-server, — HTTP-слой освоен. Осталось добавить формы.

🛠 Практика к уроку 7

Подключите сервис к mock API через HttpClient и обработайте состояния UI.

Задание 1. CRUD через HttpClient

Контекст. Мини-версия транспортного слоя: четыре метода на все операции с задачами.
💡 Аналогия. Как четыре кнопки на пульте склада: «взять всё» (GET), «положить» (POST), «поправить» (PATCH), «выбросить» (DELETE).
Что увидит пользователь. Готовый набор команд для сервиса TaskApi.
getAll()   { return this.http.get<Task[]>(this.base); }
create(d)  { return this.http.post<Task>(this.base, d); }
update(t)  { return this.http.patch<Task>(`${this.base}/${t.id}`, t); }
remove(id) { return this.http.delete<void>(`${this.base}/${id}`); }

Задание 2. Обработка ошибок

Контекст. При сбое вместо падения возвращаем пустой список, чтобы приложение не сломалось.
💡 Аналогия. Сетка безопасности подхватила акробата и поставила на ноги — «пока пусто, но живы».
Что увидит пользователь. Вместо краха — пустой список и запись ошибки в консоли (надо доработать до сообщения).
return this.http.get<Task[]>(this.base).pipe(
  catchError(err => { console.error(err); return of<Task[]>([]); })
);
Полная версия. Практика: Урок 7 → — provideHttpClient, конечный автомат UI и песочница состояний.
Куда дальше: закрепи HTTP в практике к уроку 7, а сначала потренируйся в лабораторной №1 (клик → HTTP).

📖 Глоссарий

ТерминЖизненная аналогияКоротко
HTTP-запросЗаказ в магазинеТы послал заявку на сервер и ждёшь либо посылку с данными, либо отказ.
HTTP-статусОтвет продавца200 — «есть», 404 — «такого нет», 500 — «на складе авария». Код говорит, чем закончился заказ.
fetch / HttpClientКурьерСам едет на склад (сервер), забирает данные и привозит их тебе. Ты никуда не ходишь.
catchErrorСетка безопасностиЛовит падение запроса, чтобы программа не разбилась, и возвращает аккуратный результат.
JSONЭтикетка на посылкеПишет «поле: значение» парами, чтобы браузер понял, что лежит внутри ответа.
loading / empty / errorТри состояния посылкиВ пути (крутится спиннер) / приехала пустой / потерялась (ошибка).
signalГруппа в мессенджереПоменял значение — и все подписчики сразу получили сообщение и обновили экран.

🧠 Самопроверка

1. HTTP-статус 404 означает, что сервер нашёл нужные данные. Верно или неверно?

2. catchError похож на сетку безопасности: он ловит ошибку запроса, чтобы приложение не упало. Верно или неверно?

3. HttpClient в Angular работает как курьер: сам ходит на сервер и привозит данные, а ты остаёшься на месте. Верно или неверно?

Прогресс курса