ADR-UI-008. Управление состоянием
Состояние живёт на нескольких уровнях: локальное (компонент), страничное (несколько компонентов одной страницы), разделяемое (несколько частей приложения). Фиксируем один способ — signals + injectable-сервисы, без параллельных подходов и без глобального store.
1. Контекст и постановка задачи
Состоянием надо управлять на трёх уровнях:
- локальное — внутри компонента (текущий шаг, открытость блока, прогресс);
- страничное — общее для нескольких компонентов одной страницы;
- разделяемое — нужное нескольким частям приложения (профиль, конфиг).
Нужен единый способ — без смеси signals / BehaviorSubject / глобального store.
2. Драйверы решения
- Минимум абстракций — требований к undo/time-travel/оффлайну нет; глобальный store избыточен.
- Явный поток данных — компонент → сервис → API; без неявных глобальных «магнитов».
- Нативные средства Angular — signals +
computed()+toSignal()покрывают почти всё. - Локальность — состояние живёт там, где используется; удаление фичи удаляет её состояние.
3. Рассмотренные варианты
- A. Глобальный store (NgRx / elf) — универсально, но избыточно; лишняя зависимость.
- B. Signals + injectable-сервисы — локальное в сигналах компонента; разделяемое — в
providedIn:'root'-сервисе с сигналами. - C. RxJS Subjects во всём — проигрывает signals по эргономике в шаблонах и
computed.
4. Решение
Выбран Вариант B: signals + injectable-сервисы.
4.1. Уровни состояния
| Уровень | Где живёт | Срок жизни |
|---|---|---|
| Компонентное | Сигналы внутри компонента | жизнь компонента |
| Страничное | Per-page service в providers страницы (ADR-UI-013) |
жизнь страницы |
| Разделяемое | @Injectable({ providedIn: 'root' }) сервис с сигналами |
жизнь приложения |
4.2. Компонентное состояние
signal()/computed()внутри компонента; изменения только черезset()/update()(не мутируем объект).- Производное — через
computed(), не копией в нескольких сигналах.
4.3. Страничное состояние
- Нетривиальная страница имеет per-page service, зарегистрированный в
providersстраницы (живёт ровно пока активна страница). - Внутри — сигналы; компонент и под-компоненты читают один экземпляр через
inject(...). - Данные из API кладутся в сигнал сервиса (или компонент подписывается через
takeUntilDestroyed()и кладёт в свой сигнал).
4.4. Разделяемое состояние
- Только для данных, реально нужных нескольким частям (профиль, конфиг).
@Injectable({ providedIn: 'root' })вsrc/app/services/.- Контракт — сигналы для чтения (
asReadonly()) + методы для записи. Прямая запись в сигнал извне запрещена (private signal + публичный readonly).
4.5. Подписки и Observable
- Угасание потоков —
takeUntilDestroyed()+DestroyRef(безuntil-destroy, ADR-UI-001). - Observable → Signal —
toSignal(stream$), по возможности с явнымinitialValue.
4.6. Чего не делаем
- Глобальный store (NgRx/elf и аналоги).
- Запись через мутацию — только
set/update. - Скрытые синглтоны (
static-поля, модули-синглтоны как имитация store) — разделяемое всегда в инжектируемом сервисе. - Локальный store на странице через RxJS Subject — замена: per-page service с сигналами.
5. Положительные следствия
- Один способ работы со состоянием — меньше развилок для разработчика и ИИ.
- Сигналы интегрированы с Angular (change detection, шаблоны,
computed) без лишних операторов. - Удаление страницы/фичи механически удаляет её состояние.
6. Отрицательные следствия и компромиссы
- Нет time-travel / Redux DevTools — при появлении требований к сложной отладке заведём отдельный ADR на store.
- Оптимистичные обновления/откат реализуются вручную в сервисе.
7. Проверка
- В коде нет импортов глобального store.
- Компоненты с локальным состоянием используют сигналы (нет состояния в полях класса вне сигналов и форм).
- Разделяемое состояние читается через
inject(<Service>).<signal>()и пишется только методами сервиса.
8. Открытые вопросы / отложено
- Глобальный store — пересмотрим при требованиях к time-travel / оффлайну / синхронизации вкладок.
- Кэширование данных между страницами (справочники) — пока per-page; общий уровень — отдельным ADR.